├── tests ├── .gitignore ├── __init__.py ├── test_configuration.py ├── conftest.py ├── test_cloud.py ├── test_exceptions.py ├── asynctests │ └── test_async_arm_polling.py ├── test_operation.py └── test_arm_polling.py ├── setup.cfg ├── MANIFEST.in ├── doc ├── requirements.txt ├── index.md ├── make.bat └── conf.py ├── dev_requirements.txt ├── .gitignore ├── tox.ini ├── LICENSE.md ├── msrestazure ├── polling │ ├── __init__.py │ ├── async_arm_polling.py │ └── arm_polling.py ├── version.py ├── __init__.py ├── azure_configuration.py ├── azure_exceptions.py ├── tools.py ├── azure_cloud.py └── azure_operation.py ├── .travis.yml ├── setup.py ├── SECURITY.md ├── msrestazure.pyproj └── README.rst /tests/.gitignore: -------------------------------------------------------------------------------- 1 | credentials.json -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst 2 | recursive-include tests *.py 3 | -------------------------------------------------------------------------------- /doc/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinx_rtd_theme 3 | recommonmark -------------------------------------------------------------------------------- /dev_requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | mock;python_version<="2.7" 3 | httpretty 4 | coverage<5.0.0 5 | pytest 6 | pytest-cov 7 | pytest-asyncio;python_full_version>="3.5.2" 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | .venv 4 | msrestazure.egg-info 5 | .tox 6 | tests/.coverage 7 | autorest 8 | .pytest_cache 9 | .cache 10 | .coverage 11 | coverage.xml 12 | Pipfile.lock 13 | build 14 | dist 15 | .vscode -------------------------------------------------------------------------------- /doc/index.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | msrestazure's documentation has moved from ReadTheDocs to docs.microsoft.com. 6 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist=py27, py35 3 | skipsdist=True 4 | 5 | [testenv] 6 | setenv = 7 | PYTHONPATH = {toxinidir}:{toxinidir}/msrestazure 8 | PythonLogLevel=30 9 | deps= 10 | -rdev_requirements.txt 11 | autorest: requests>=2.14.0 12 | commands= 13 | pytest --cov=msrestazure tests/ 14 | autorest: pytest --cov=msrestazure --cov-append autorest.python/test/azure/ 15 | coverage report --fail-under=40 16 | coverage xml --ignore-errors # At this point, don't fail for "async" keyword in 2.7/3.4 -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Microsoft Azure 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /msrestazure/polling/__init__.py: -------------------------------------------------------------------------------- 1 | # -------------------------------------------------------------------------- 2 | # 3 | # Copyright (c) Microsoft Corporation. All rights reserved. 4 | # 5 | # The MIT License (MIT) 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the ""Software""), to 9 | # deal in the Software without restriction, including without limitation the 10 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 11 | # sell copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 22 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 23 | # IN THE SOFTWARE. 24 | # 25 | # -------------------------------------------------------------------------- 26 | -------------------------------------------------------------------------------- /msrestazure/version.py: -------------------------------------------------------------------------------- 1 | # -------------------------------------------------------------------------- 2 | # 3 | # Copyright (c) Microsoft Corporation. All rights reserved. 4 | # 5 | # The MIT License (MIT) 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the ""Software""), to 9 | # deal in the Software without restriction, including without limitation the 10 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 11 | # sell copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 22 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 23 | # IN THE SOFTWARE. 24 | # 25 | # -------------------------------------------------------------------------- 26 | 27 | #: version of the package. Use msrestazure.__version__ instead. 28 | msrestazure_version = "0.6.4.post1" 29 | -------------------------------------------------------------------------------- /msrestazure/__init__.py: -------------------------------------------------------------------------------- 1 | # -------------------------------------------------------------------------- 2 | # 3 | # Copyright (c) Microsoft Corporation. All rights reserved. 4 | # 5 | # The MIT License (MIT) 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the ""Software""), to 9 | # deal in the Software without restriction, including without limitation the 10 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 11 | # sell copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 22 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 23 | # IN THE SOFTWARE. 24 | # 25 | # -------------------------------------------------------------------------- 26 | 27 | 28 | from .azure_configuration import AzureConfiguration 29 | from .version import msrestazure_version 30 | 31 | __all__ = ["AzureConfiguration"] 32 | 33 | __version__ = msrestazure_version 34 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | sudo: required 3 | language: python 4 | cache: pip 5 | _autorest_install: &_autorest_install 6 | before_install: 7 | - git clone --recursive https://github.com/Azure/autorest.python.git 8 | - sudo apt-get install libunwind8-dev 9 | - nvm install 8 10 | - pushd autorest.python 11 | - npm install # Install test server pre-requisites 12 | - popd 13 | matrix: 14 | include: 15 | - python: 2.7 16 | env: TOXENV=py27 17 | - python: 3.5 18 | env: TOXENV=py35 19 | - python: 3.6 20 | env: TOXENV=py36 21 | - python: 3.7 22 | env: TOXENV=py37 23 | - python: 3.8 24 | env: TOXENV=py38 25 | - python: 2.7 26 | env: TOXENV=py27-autorest 27 | <<: *_autorest_install 28 | - python: 3.5 29 | env: TOXENV=py35-autorest 30 | <<: *_autorest_install 31 | - python: 3.6 32 | env: TOXENV=py36-autorest 33 | <<: *_autorest_install 34 | - python: 3.7 35 | env: TOXENV=py37-autorest 36 | <<: *_autorest_install 37 | - python: 3.8 38 | env: TOXENV=py38-autorest 39 | <<: *_autorest_install 40 | allow_failures: 41 | - env: TOXENV=py27-autorest 42 | - env: TOXENV=py35-autorest 43 | - env: TOXENV=py36-autorest 44 | - env: TOXENV=py37-autorest 45 | - env: TOXENV=py38-autorest 46 | install: 47 | - pip install tox 48 | script: 49 | - tox 50 | after_success: 51 | - bash <(curl -s https://codecov.io/bash) -e TOXENV -f $TRAVIS_BUILD_DIR/test/coverage.xml 52 | deploy: 53 | provider: pypi 54 | user: Laurent.Mazuel 55 | skip_upload_docs: true 56 | skip_cleanup: true 57 | # password: use $PYPI_PASSWORD 58 | distributions: "sdist bdist_wheel" 59 | on: 60 | tags: true 61 | python: '3.6' 62 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -------------------------------------------------------------------------- 2 | # 3 | # Copyright (c) Microsoft Corporation. All rights reserved. 4 | # 5 | # The MIT License (MIT) 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the ""Software""), to 9 | # deal in the Software without restriction, including without limitation the 10 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 11 | # sell copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 22 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 23 | # IN THE SOFTWARE. 24 | # 25 | # -------------------------------------------------------------------------- 26 | 27 | import os 28 | from unittest import TestLoader, TextTestRunner 29 | 30 | 31 | if __name__ == '__main__': 32 | 33 | runner = TextTestRunner(verbosity=2) 34 | test_dir = os.path.dirname(__file__) 35 | 36 | test_loader = TestLoader() 37 | suite = test_loader.discover(test_dir, pattern="unittest_*.py") 38 | runner.run(suite) 39 | -------------------------------------------------------------------------------- /tests/test_configuration.py: -------------------------------------------------------------------------------- 1 | #-------------------------------------------------------------------------- 2 | # 3 | # Copyright (c) Microsoft Corporation. All rights reserved. 4 | # 5 | # The MIT License (MIT) 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the ""Software""), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | # THE SOFTWARE. 24 | # 25 | #-------------------------------------------------------------------------- 26 | import unittest 27 | 28 | from msrestazure.azure_configuration import AzureConfiguration 29 | 30 | class TestAzConfiguration(unittest.TestCase): 31 | 32 | def test_config_basic(self): 33 | # Do not raise is already something, we don't really need strong tests here. 34 | AzureConfiguration('http://management.something.com') 35 | 36 | if __name__ == '__main__': 37 | unittest.main() 38 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -------------------------------------------------------------------------- 2 | # 3 | # Copyright (c) Microsoft Corporation. All rights reserved. 4 | # 5 | # The MIT License (MIT) 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the ""Software""), to 9 | # deal in the Software without restriction, including without limitation the 10 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 11 | # sell copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 22 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 23 | # IN THE SOFTWARE. 24 | # 25 | # -------------------------------------------------------------------------- 26 | 27 | from setuptools import setup, find_packages 28 | 29 | setup( 30 | name='msrestazure', 31 | version='0.6.4.post1', 32 | author='Microsoft Corporation', 33 | author_email='azpysdkhelp@microsoft.com', 34 | packages=find_packages(exclude=["tests", "tests.*"]), 35 | url='https://github.com/Azure/msrestazure-for-python', 36 | license='MIT License', 37 | description=('AutoRest swagger generator Python client runtime. ' 38 | 'Azure-specific module.'), 39 | long_description=open('README.rst').read(), 40 | classifiers=[ 41 | 'Development Status :: 7 - Inactive', 42 | 'Programming Language :: Python', 43 | 'Programming Language :: Python :: 2', 44 | 'Programming Language :: Python :: 2.7', 45 | 'Programming Language :: Python :: 3', 46 | 'Programming Language :: Python :: 3.5', 47 | 'Programming Language :: Python :: 3.6', 48 | 'Programming Language :: Python :: 3.7', 49 | 'Programming Language :: Python :: 3.8', 50 | 'License :: OSI Approved :: MIT License', 51 | 'Topic :: Software Development'], 52 | install_requires=[ 53 | "msrest>=0.6.0,<2.0.0", 54 | "adal>=0.6.0,<2.0.0", 55 | "six", 56 | ], 57 | ) 58 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | #-------------------------------------------------------------------------- 2 | # 3 | # Copyright (c) Microsoft Corporation. All rights reserved. 4 | # 5 | # The MIT License (MIT) 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the ""Software""), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | # THE SOFTWARE. 24 | # 25 | #-------------------------------------------------------------------------- 26 | import json 27 | import os.path 28 | import sys 29 | 30 | import pytest 31 | 32 | CWD = os.path.dirname(__file__) 33 | 34 | # Ignore collection of async tests for Python 2 35 | collect_ignore = [] 36 | if sys.version_info < (3, 5): 37 | collect_ignore.append("asynctests") 38 | 39 | 40 | def pytest_addoption(parser): 41 | parser.addoption("--runslow", action="store_true", 42 | default=False, help="run slow tests") 43 | 44 | def pytest_collection_modifyitems(config, items): 45 | if config.getoption("--runslow"): 46 | # --runslow given in cli: do not skip slow tests 47 | return 48 | skip_slow = pytest.mark.skip(reason="need --runslow option to run") 49 | for item in items: 50 | if "slow" in item.keywords: 51 | item.add_marker(skip_slow) 52 | 53 | 54 | @pytest.fixture 55 | def user_password(): 56 | filepath = os.path.join(CWD, "credentials.json") 57 | if os.path.exists(filepath): 58 | with open(filepath, "r") as fd: 59 | userpass = json.load(fd)["userpass"] 60 | return userpass["user"], userpass["password"] 61 | raise ValueError("Create a {} file with a 'userpass' key and two keys 'user' and 'password'".format( 62 | filepath 63 | )) 64 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/opensource/security/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/opensource/security/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/opensource/security/pgpkey). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://aka.ms/opensource/security/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/opensource/security/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/opensource/security/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /msrestazure.pyproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Debug 5 | 2.0 6 | {b80e5ecc-dcdc-4d31-b3be-8c32dea8e864} 7 | 8 | 9 | msrestazure\__init__.py 10 | ..\msrest\;. 11 | . 12 | . 13 | clientruntime 14 | client_runtime 15 | 16 | 17 | 18 | 19 | 20 | 21 | true 22 | false 23 | 24 | 25 | true 26 | false 27 | 28 | 29 | 30 | Code 31 | 32 | 33 | Code 34 | 35 | 36 | Code 37 | 38 | 39 | 40 | 41 | Code 42 | 43 | 44 | Code 45 | 46 | 47 | 48 | 49 | Code 50 | 51 | 52 | Code 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | Code 62 | 63 | 64 | 65 | 10.0 66 | $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\Python Tools\Microsoft.PythonTools.targets 67 | 68 | 69 | 70 | 73 | 74 | 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /tests/test_cloud.py: -------------------------------------------------------------------------------- 1 | #-------------------------------------------------------------------------- 2 | # 3 | # Copyright (c) Microsoft Corporation. All rights reserved. 4 | # 5 | # The MIT License (MIT) 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the ""Software""), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | # THE SOFTWARE. 24 | # 25 | #-------------------------------------------------------------------------- 26 | 27 | import unittest 28 | import json 29 | import httpretty 30 | import requests 31 | 32 | from msrestazure import azure_cloud 33 | 34 | class TestCloud(unittest.TestCase): 35 | 36 | @httpretty.activate 37 | def test_get_cloud_from_endpoint(self): 38 | 39 | public_azure_dict = { 40 | "galleryEndpoint": "https://gallery.azure.com", 41 | "graphEndpoint": "https://graph.windows.net/", 42 | "portalEndpoint": "https://portal.azure.com", 43 | "authentication": { 44 | "loginEndpoint": "https://login.windows.net", 45 | "audiences": ["https://management.core.windows.net/", "https://management.azure.com/"] 46 | } 47 | } 48 | 49 | httpretty.register_uri(httpretty.GET, 50 | "https://management.azure.com/metadata/endpoints?api-version=1.0", 51 | body=json.dumps(public_azure_dict), 52 | content_type="application/json") 53 | 54 | cloud = azure_cloud.get_cloud_from_metadata_endpoint("https://management.azure.com") 55 | self.assertEqual("https://management.azure.com", cloud.name) 56 | self.assertEqual("https://management.azure.com", cloud.endpoints.management) 57 | self.assertEqual("https://management.azure.com", cloud.endpoints.resource_manager) 58 | self.assertEqual("https://gallery.azure.com", cloud.endpoints.gallery) 59 | self.assertEqual("https://graph.windows.net/", cloud.endpoints.active_directory_graph_resource_id) 60 | self.assertEqual("https://login.windows.net", cloud.endpoints.active_directory) 61 | 62 | session = requests.Session() 63 | cloud = azure_cloud.get_cloud_from_metadata_endpoint("https://management.azure.com", "Public Azure", session) 64 | self.assertEqual("Public Azure", cloud.name) 65 | self.assertEqual("https://management.azure.com", cloud.endpoints.management) 66 | self.assertEqual("https://management.azure.com", cloud.endpoints.resource_manager) 67 | self.assertEqual("https://gallery.azure.com", cloud.endpoints.gallery) 68 | self.assertEqual("https://graph.windows.net/", cloud.endpoints.active_directory_graph_resource_id) 69 | self.assertEqual("https://login.windows.net", cloud.endpoints.active_directory) 70 | 71 | with self.assertRaises(azure_cloud.MetadataEndpointError): 72 | azure_cloud.get_cloud_from_metadata_endpoint("https://something.azure.com") 73 | 74 | with self.assertRaises(azure_cloud.CloudEndpointNotSetException): 75 | cloud.endpoints.batch_resource_id 76 | 77 | with self.assertRaises(azure_cloud.CloudSuffixNotSetException): 78 | cloud.suffixes.sql_server_hostname 79 | 80 | self.assertIsNotNone(str(cloud)) 81 | -------------------------------------------------------------------------------- /msrestazure/azure_configuration.py: -------------------------------------------------------------------------------- 1 | # -------------------------------------------------------------------------- 2 | # 3 | # Copyright (c) Microsoft Corporation. All rights reserved. 4 | # 5 | # The MIT License (MIT) 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the ""Software""), to 9 | # deal in the Software without restriction, including without limitation the 10 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 11 | # sell copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 22 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 23 | # IN THE SOFTWARE. 24 | # 25 | # -------------------------------------------------------------------------- 26 | 27 | try: 28 | from configparser import NoOptionError 29 | except ImportError: 30 | from ConfigParser import NoOptionError 31 | 32 | import logging 33 | 34 | from msrest import Configuration 35 | from msrest.exceptions import raise_with_traceback 36 | 37 | from .version import msrestazure_version 38 | from .tools import register_rp_hook 39 | 40 | _LOGGER = logging.getLogger(__name__) 41 | 42 | class AzureConfiguration(Configuration): 43 | """Azure specific client configuration. 44 | 45 | :param str base_url: REST Service base URL. 46 | :param str filepath: Path to an existing config file (optional). 47 | """ 48 | 49 | def __init__(self, base_url, filepath=None): 50 | super(AzureConfiguration, self).__init__(base_url) 51 | self.long_running_operation_timeout = 30 52 | self.accept_language = 'en-US' 53 | self.generate_client_request_id = True 54 | self.add_user_agent("msrest_azure/{}".format(msrestazure_version)) 55 | 56 | # ARM requires 20seconds at least. Putting 4 here is 24seconds 57 | self.retry_policy.retries = 4 58 | 59 | if filepath: 60 | self.load(filepath) 61 | 62 | # Check if "hasattr", just in case msrest is older than msrestazure 63 | if hasattr(self, 'hooks'): 64 | self.hooks.append(register_rp_hook) 65 | else: 66 | _LOGGER.warning(("Your 'msrest' version is too old to activate all the " 67 | "features of 'msrestazure'. Please update using" 68 | "'pip install -U msrest'")) 69 | 70 | def save(self, filepath): 71 | """Save current configuration to file. 72 | 73 | :param str filepath: Path to save file to. 74 | :raises: ValueError if supplied filepath cannot be written to. 75 | """ 76 | self._config.add_section("Azure") 77 | self._config.set("Azure", 78 | "long_running_operation_timeout", 79 | self.long_running_operation_timeout) 80 | return super(AzureConfiguration, self).save(filepath) 81 | 82 | def load(self, filepath): 83 | """Load configuration from existing file. 84 | 85 | :param str filepath: Path to existing config file. 86 | :raises: ValueError if supplied config file is invalid. 87 | """ 88 | try: 89 | self._config.read(filepath) 90 | self.long_running_operation_timeout = self._config.getint( 91 | "Azure", "long_running_operation_timeout") 92 | except (ValueError, EnvironmentError, NoOptionError): 93 | msg = "Supplied config file incompatible" 94 | raise_with_traceback(ValueError, msg) 95 | finally: 96 | self._clear_config() 97 | return super(AzureConfiguration, self).load(filepath) 98 | -------------------------------------------------------------------------------- /msrestazure/polling/async_arm_polling.py: -------------------------------------------------------------------------------- 1 | # -------------------------------------------------------------------------- 2 | # 3 | # Copyright (c) Microsoft Corporation. All rights reserved. 4 | # 5 | # The MIT License (MIT) 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the ""Software""), to 9 | # deal in the Software without restriction, including without limitation the 10 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 11 | # sell copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 22 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 23 | # IN THE SOFTWARE. 24 | # 25 | # -------------------------------------------------------------------------- 26 | import asyncio 27 | 28 | from ..azure_exceptions import CloudError 29 | from .arm_polling import ( 30 | failed, 31 | BadStatus, 32 | BadResponse, 33 | OperationFailed, 34 | ARMPolling 35 | ) 36 | 37 | __all__ = ["AsyncARMPolling"] 38 | 39 | class AsyncARMPolling(ARMPolling): 40 | """A subclass or ARMPolling that redefine "run" as async. 41 | """ 42 | 43 | async def run(self): 44 | try: 45 | await self._poll() 46 | except BadStatus: 47 | self._operation.status = 'Failed' 48 | raise CloudError(self._response) 49 | 50 | except BadResponse as err: 51 | self._operation.status = 'Failed' 52 | raise CloudError(self._response, str(err)) 53 | 54 | except OperationFailed: 55 | raise CloudError(self._response) 56 | 57 | async def _poll(self): 58 | """Poll status of operation so long as operation is incomplete and 59 | we have an endpoint to query. 60 | 61 | :param callable update_cmd: The function to call to retrieve the 62 | latest status of the long running operation. 63 | :raises: OperationFailed if operation status 'Failed' or 'Cancelled'. 64 | :raises: BadStatus if response status invalid. 65 | :raises: BadResponse if response invalid. 66 | """ 67 | 68 | while not self.finished(): 69 | await self._delay() 70 | await self.update_status() 71 | 72 | if failed(self._operation.status): 73 | raise OperationFailed("Operation failed or cancelled") 74 | 75 | elif self._operation.should_do_final_get(): 76 | if self._operation.method == 'POST' and self._operation.location_url: 77 | final_get_url = self._operation.location_url 78 | else: 79 | final_get_url = self._operation.initial_response.request.url 80 | self._response = await self.request_status(final_get_url) 81 | self._operation.get_status_from_resource(self._response) 82 | 83 | async def _delay(self): 84 | """Check for a 'retry-after' header to set timeout, 85 | otherwise use configured timeout. 86 | """ 87 | if self._response is None: 88 | await asyncio.sleep(0) 89 | if self._response.headers.get('retry-after'): 90 | await asyncio.sleep(int(self._response.headers['retry-after'])) 91 | else: 92 | await asyncio.sleep(self._timeout) 93 | 94 | async def update_status(self): 95 | """Update the current status of the LRO. 96 | """ 97 | if self._operation.async_url: 98 | self._response = await self.request_status(self._operation.async_url) 99 | self._operation.set_async_url_if_present(self._response) 100 | self._operation.get_status_from_async(self._response) 101 | elif self._operation.location_url: 102 | self._response = await self.request_status(self._operation.location_url) 103 | self._operation.set_async_url_if_present(self._response) 104 | self._operation.get_status_from_location(self._response) 105 | elif self._operation.method == "PUT": 106 | initial_url = self._operation.initial_response.request.url 107 | self._response = await self.request_status(initial_url) 108 | self._operation.set_async_url_if_present(self._response) 109 | self._operation.get_status_from_resource(self._response) 110 | else: 111 | raise BadResponse("Unable to find status link for polling.") 112 | 113 | async def request_status(self, status_link): 114 | """Do a simple GET to this status link. 115 | 116 | This method re-inject 'x-ms-client-request-id'. 117 | 118 | :rtype: requests.Response 119 | """ 120 | # ARM requires to re-inject 'x-ms-client-request-id' while polling 121 | header_parameters = { 122 | 'x-ms-client-request-id': self._operation.initial_response.request.headers['x-ms-client-request-id'] 123 | } 124 | request = self._client.get(status_link, headers=header_parameters) 125 | return await self._client.async_send(request, stream=False, **self._operation_config) 126 | -------------------------------------------------------------------------------- /doc/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\pydocumentdb.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\pydocumentdb.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # azure-sdk-for-python documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Jun 27 15:42:45 2014. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | import pip 18 | 19 | # If extensions (or modules to document with autodoc) are in another directory, 20 | # add these directories to sys.path here. If the directory is relative to the 21 | # documentation root, use os.path.abspath to make it absolute, like shown here. 22 | sys.path.insert(0, os.path.abspath('../')) 23 | 24 | # -- General configuration ------------------------------------------------ 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | #needs_sphinx = '1.0' 28 | 29 | # Add any Sphinx extension module names here, as strings. They can be 30 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 31 | # ones. 32 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.autosummary', 'sphinx.ext.doctest', 33 | 'sphinx.ext.viewcode', 'sphinx.ext.intersphinx'] 34 | 35 | intersphinx_mapping = { 36 | 'python': ('https://docs.python.org/3.5', None), 37 | 'msrest': ('http://msrest.readthedocs.org/en/latest/', None), 38 | 'requests': ('http://docs.python-requests.org/en/master/', None) 39 | } 40 | 41 | # Add any paths that contain templates here, relative to this directory. 42 | templates_path = ['_templates'] 43 | 44 | source_parsers = { 45 | '.md': 'recommonmark.parser.CommonMarkParser', 46 | } 47 | 48 | # The suffix of source filenames. 49 | source_suffix = ['.rst', '.md'] 50 | 51 | # The encoding of source files. 52 | #source_encoding = 'utf-8-sig' 53 | 54 | # The master toctree document. 55 | master_doc = 'index' 56 | 57 | # General information about the project. 58 | project = u'msrestazure' 59 | copyright = u'2016-2018, Microsoft' 60 | 61 | # The version info for the project you're documenting, acts as replacement for 62 | # |version| and |release|, also used in various other places throughout the 63 | # built documents. 64 | # 65 | # The short X.Y version. 66 | version = '0.5.1' 67 | # The full version, including alpha/beta/rc tags. 68 | release = '0.5.1' 69 | 70 | # The language for content autogenerated by Sphinx. Refer to documentation 71 | # for a list of supported languages. 72 | #language = None 73 | 74 | # There are two options for replacing |today|: either, you set today to some 75 | # non-false value, then it is used: 76 | #today = '' 77 | # Else, today_fmt is used as the format for a strftime call. 78 | #today_fmt = '%B %d, %Y' 79 | 80 | # List of patterns, relative to source directory, that match files and 81 | # directories to ignore when looking for source files. 82 | exclude_patterns = ['_build'] 83 | 84 | # The reST default role (used for this markup: `text`) to use for all 85 | # documents. 86 | #default_role = None 87 | 88 | # If true, '()' will be appended to :func: etc. cross-reference text. 89 | #add_function_parentheses = True 90 | 91 | # If true, the current module name will be prepended to all description 92 | # unit titles (such as .. function::). 93 | #add_module_names = True 94 | 95 | # If true, sectionauthor and moduleauthor directives will be shown in the 96 | # output. They are ignored by default. 97 | #show_authors = False 98 | 99 | # The name of the Pygments (syntax highlighting) style to use. 100 | pygments_style = 'sphinx' 101 | 102 | # A list of ignored prefixes for module index sorting. 103 | #modindex_common_prefix = [] 104 | 105 | # If true, keep warnings as "system message" paragraphs in the built documents. 106 | #keep_warnings = False 107 | 108 | # -- Options for extensions ---------------------------------------------------- 109 | autoclass_content = 'both' 110 | 111 | # -- Options for HTML output ---------------------------------------------- 112 | 113 | # The theme to use for HTML and HTML Help pages. See the documentation for 114 | # a list of builtin themes. 115 | #html_theme = 'default' 116 | #html_theme_options = {'collapsiblesidebar': True} 117 | 118 | # Activate the theme. 119 | #pip.main(['install', 'sphinx_bootstrap_theme']) 120 | #import sphinx_bootstrap_theme 121 | #html_theme = 'bootstrap' 122 | #html_theme_path = sphinx_bootstrap_theme.get_html_theme_path() 123 | 124 | # Theme options are theme-specific and customize the look and feel of a theme 125 | # further. For a list of options available for each theme, see the 126 | # documentation. 127 | #html_theme_options = {} 128 | 129 | # Add any paths that contain custom themes here, relative to this directory. 130 | #html_theme_path = [] 131 | 132 | # The name for this set of Sphinx documents. If None, it defaults to 133 | # " v documentation". 134 | #html_title = None 135 | 136 | # A shorter title for the navigation bar. Default is the same as html_title. 137 | #html_short_title = None 138 | 139 | # The name of an image file (relative to this directory) to place at the top 140 | # of the sidebar. 141 | #html_logo = None 142 | 143 | # The name of an image file (within the static path) to use as favicon of the 144 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 145 | # pixels large. 146 | #html_favicon = None 147 | 148 | # Add any paths that contain custom static files (such as style sheets) here, 149 | # relative to this directory. They are copied after the builtin static files, 150 | # so a file named "default.css" will overwrite the builtin "default.css". 151 | # html_static_path = ['_static'] 152 | 153 | # Add any extra paths that contain custom files (such as robots.txt or 154 | # .htaccess) here, relative to this directory. These files are copied 155 | # directly to the root of the documentation. 156 | #html_extra_path = [] 157 | 158 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 159 | # using the given strftime format. 160 | #html_last_updated_fmt = '%b %d, %Y' 161 | 162 | # If true, SmartyPants will be used to convert quotes and dashes to 163 | # typographically correct entities. 164 | #html_use_smartypants = True 165 | 166 | # Custom sidebar templates, maps document names to template names. 167 | #html_sidebars = {} 168 | 169 | # Additional templates that should be rendered to pages, maps page names to 170 | # template names. 171 | #html_additional_pages = {} 172 | 173 | # If false, no module index is generated. 174 | #html_domain_indices = True 175 | 176 | # If false, no index is generated. 177 | #html_use_index = True 178 | 179 | # If true, the index is split into individual pages for each letter. 180 | #html_split_index = False 181 | 182 | # If true, links to the reST sources are added to the pages. 183 | #html_show_sourcelink = True 184 | 185 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 186 | #html_show_sphinx = True 187 | 188 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 189 | #html_show_copyright = True 190 | 191 | # If true, an OpenSearch description file will be output, and all pages will 192 | # contain a tag referring to it. The value of this option must be the 193 | # base URL from which the finished HTML is served. 194 | #html_use_opensearch = '' 195 | 196 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 197 | #html_file_suffix = None 198 | 199 | # Output file base name for HTML help builder. 200 | htmlhelp_basename = 'msrestazure-doc' 201 | 202 | 203 | # -- Options for LaTeX output --------------------------------------------- 204 | 205 | latex_elements = { 206 | # The paper size ('letterpaper' or 'a4paper'). 207 | #'papersize': 'letterpaper', 208 | 209 | # The font size ('10pt', '11pt' or '12pt'). 210 | #'pointsize': '10pt', 211 | 212 | # Additional stuff for the LaTeX preamble. 213 | #'preamble': '', 214 | } 215 | 216 | # Grouping the document tree into LaTeX files. List of tuples 217 | # (source start file, target name, title, 218 | # author, documentclass [howto, manual, or own class]). 219 | latex_documents = [ 220 | ('index', 'msrestazure.tex', u'msrest Documentation', 221 | u'Microsoft', 'manual'), 222 | ] 223 | 224 | # The name of an image file (relative to this directory) to place at the top of 225 | # the title page. 226 | #latex_logo = None 227 | 228 | # For "manual" documents, if this is true, then toplevel headings are parts, 229 | # not chapters. 230 | #latex_use_parts = False 231 | 232 | # If true, show page references after internal links. 233 | #latex_show_pagerefs = False 234 | 235 | # If true, show URL addresses after external links. 236 | #latex_show_urls = False 237 | 238 | # Documents to append as an appendix to all manuals. 239 | #latex_appendices = [] 240 | 241 | # If false, no module index is generated. 242 | #latex_domain_indices = True 243 | 244 | 245 | -------------------------------------------------------------------------------- /msrestazure/azure_exceptions.py: -------------------------------------------------------------------------------- 1 | # -------------------------------------------------------------------------- 2 | # 3 | # Copyright (c) Microsoft Corporation. All rights reserved. 4 | # 5 | # The MIT License (MIT) 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the ""Software""), to 9 | # deal in the Software without restriction, including without limitation the 10 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 11 | # sell copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 22 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 23 | # IN THE SOFTWARE. 24 | # 25 | # -------------------------------------------------------------------------- 26 | 27 | import json 28 | import six 29 | 30 | from requests import RequestException 31 | 32 | from msrest.exceptions import ClientException 33 | from msrest.serialization import Deserializer 34 | from msrest.exceptions import DeserializationError 35 | 36 | # TimeoutError for backward compat since it was used by former MSI code. 37 | # but this never worked on Python 2.7, so Python 2.7 users get the correct one now 38 | try: 39 | class MSIAuthenticationTimeoutError(TimeoutError, ClientException): 40 | """If the MSI authentication reached the timeout without getting a token. 41 | """ 42 | pass 43 | except NameError: 44 | class MSIAuthenticationTimeoutError(ClientException): 45 | """If the MSI authentication reached the timeout without getting a token. 46 | """ 47 | pass 48 | 49 | class CloudErrorRoot(object): 50 | """Just match the "error" key at the root of a OdataV4 JSON. 51 | """ 52 | _validation = {} 53 | _attribute_map = { 54 | 'error': {'key': 'error', 'type': 'CloudErrorData'}, 55 | } 56 | def __init__(self, error): 57 | self.error = error 58 | 59 | 60 | def _unicode_or_str(obj): 61 | try: 62 | return unicode(obj) 63 | except NameError: 64 | return str(obj) 65 | 66 | 67 | @six.python_2_unicode_compatible 68 | class CloudErrorData(object): 69 | """Cloud Error Data object, deserialized from error data returned 70 | during a failed REST API call. 71 | """ 72 | 73 | _validation = {} 74 | _attribute_map = { 75 | 'error': {'key': 'code', 'type': 'str'}, 76 | 'message': {'key': 'message', 'type': 'str'}, 77 | 'target': {'key': 'target', 'type': 'str'}, 78 | 'details': {'key': 'details', 'type': '[CloudErrorData]'}, 79 | 'innererror': {'key': 'innererror', 'type': 'object'}, 80 | 'additionalInfo': {'key': 'additionalInfo', 'type': '[TypedErrorInfo]'}, 81 | 'data': {'key': 'values', 'type': '{str}'} 82 | } 83 | 84 | def __init__(self, *args, **kwargs): 85 | self.error = kwargs.get('error') 86 | self.message = kwargs.get('message') 87 | self.request_id = None 88 | self.error_time = None 89 | self.target = kwargs.get('target') 90 | self.details = kwargs.get('details') 91 | self.innererror = kwargs.get('innererror') 92 | self.additionalInfo = kwargs.get('additionalInfo') 93 | self.data = kwargs.get('data') 94 | super(CloudErrorData, self).__init__(*args) 95 | 96 | def __str__(self): 97 | """Cloud error message.""" 98 | error_str = u"Azure Error: {}".format(self.error) 99 | error_str += u"\nMessage: {}".format(self._message) 100 | if self.target: 101 | error_str += u"\nTarget: {}".format(self.target) 102 | if self.request_id: 103 | error_str += u"\nRequest ID: {}".format(self.request_id) 104 | if self.error_time: 105 | error_str += u"\nError Time: {}".format(self.error_time) 106 | if self.data: 107 | error_str += u"\nAdditional Data:" 108 | for key, value in self.data.items(): 109 | error_str += u"\n\t{} : {}".format(key, value) 110 | if self.details: 111 | error_str += "\nException Details:" 112 | for error_obj in self.details: 113 | error_str += u"\n\tError Code: {}".format(error_obj.error) 114 | error_str += u"\n\tMessage: {}".format(error_obj.message) 115 | if error_obj.target: 116 | error_str += u"\n\tTarget: {}".format(error_obj.target) 117 | if error_obj.innererror: 118 | error_str += u"\nInner error: {}".format(json.dumps(error_obj.innererror, indent=4, ensure_ascii=False)) 119 | if error_obj.additionalInfo: 120 | error_str += u"\n\tAdditional Information:" 121 | for error_info in error_obj.additionalInfo: 122 | error_str += "\n\t\t{}".format(_unicode_or_str(error_info).replace("\n", "\n\t\t")) 123 | if self.innererror: 124 | error_str += u"\nInner error: {}".format(json.dumps(self.innererror, indent=4, ensure_ascii=False)) 125 | if self.additionalInfo: 126 | error_str += "\nAdditional Information:" 127 | for error_info in self.additionalInfo: 128 | error_str += u"\n\t{}".format(_unicode_or_str(error_info).replace("\n", "\n\t")) 129 | return error_str 130 | 131 | @classmethod 132 | def _get_subtype_map(cls): 133 | return {} 134 | 135 | @property 136 | def message(self): 137 | """Cloud error message.""" 138 | return self._message 139 | 140 | @message.setter 141 | def message(self, value): 142 | """Attempt to deconstruct error message to retrieve further 143 | error data. 144 | """ 145 | try: 146 | import ast 147 | value = ast.literal_eval(value) 148 | except (SyntaxError, TypeError, ValueError): 149 | pass 150 | try: 151 | value = value.get('value', value) 152 | msg_data = value.split('\n') 153 | self._message = msg_data[0] 154 | except AttributeError: 155 | self._message = value 156 | return 157 | try: 158 | self.request_id = msg_data[1].partition(':')[2] 159 | time_str = msg_data[2].partition(':') 160 | self.error_time = Deserializer.deserialize_iso( 161 | "".join(time_str[2:])) 162 | except (IndexError, DeserializationError): 163 | pass 164 | 165 | 166 | @six.python_2_unicode_compatible 167 | class CloudError(ClientException): 168 | """ClientError, exception raised for failed Azure REST call. 169 | Will attempt to deserialize response into meaningful error 170 | data. 171 | 172 | :param requests.Response response: Response object. 173 | :param str error: Optional error message. 174 | """ 175 | 176 | def __init__(self, response, error=None, *args, **kwargs): 177 | self.deserializer = Deserializer({ 178 | 'CloudErrorRoot': CloudErrorRoot, 179 | 'CloudErrorData': CloudErrorData, 180 | 'TypedErrorInfo': TypedErrorInfo 181 | }) 182 | self.error = None 183 | self.message = None 184 | self.response = response 185 | self.status_code = self.response.status_code 186 | self.request_id = None 187 | 188 | if error: 189 | self.message = error 190 | self.error = response 191 | else: 192 | self._build_error_data(response) 193 | 194 | if not self.error or not self.message: 195 | self._build_error_message(response) 196 | 197 | super(CloudError, self).__init__( 198 | self.message, self.error, *args, **kwargs) 199 | 200 | def __str__(self): 201 | """Cloud error message""" 202 | if self.error: 203 | return _unicode_or_str(self.error) 204 | return _unicode_or_str(self.message) 205 | 206 | def _build_error_data(self, response): 207 | try: 208 | self.error = self.deserializer('CloudErrorRoot', response).error 209 | except DeserializationError: 210 | self.error = None 211 | except AttributeError: 212 | # So far seen on Autorest test server only. 213 | self.error = None 214 | else: 215 | if self.error: 216 | if not self.error.error or not self.error.message: 217 | self.error = None 218 | else: 219 | self.message = self.error.message 220 | 221 | def _get_state(self, content): 222 | state = content.get("status") 223 | if not state: 224 | resource_content = content.get('properties', content) 225 | state = resource_content.get("provisioningState") 226 | return "Resource state {}".format(state) if state else "none" 227 | 228 | def _build_error_message(self, response): 229 | # Assume ClientResponse has "body", and otherwise it's a requests.Response 230 | content = response.text() if hasattr(response, "body") else response.text 231 | try: 232 | data = json.loads(content) 233 | except ValueError: 234 | message = "none" 235 | else: 236 | try: 237 | message = data.get("message", self._get_state(data)) 238 | except AttributeError: # data is not a dict, but is a requests.Response parsable as JSON 239 | message = str(content) 240 | try: 241 | response.raise_for_status() 242 | except RequestException as err: 243 | if not self.error: 244 | self.error = err 245 | if not self.message: 246 | if message == "none": 247 | message = str(err) 248 | msg = "Operation failed with status: {!r}. Details: {}" 249 | self.message = msg.format(response.reason, message) 250 | else: 251 | if not self.error: 252 | self.error = response 253 | if not self.message: 254 | msg = "Operation failed with status: {!r}. Details: {}" 255 | self.message = msg.format( 256 | response.status_code, message) 257 | 258 | 259 | @six.python_2_unicode_compatible 260 | class TypedErrorInfo(object): 261 | """Typed Error Info object, deserialized from error data returned 262 | during a failed REST API call. Contains additional error information 263 | """ 264 | 265 | _validation = {} 266 | _attribute_map = { 267 | 'type': {'key': 'type', 'type': 'str'}, 268 | 'info': {'key': 'info', 'type': 'object'} 269 | } 270 | 271 | def __init__(self, type, info): 272 | self.type = type 273 | self.info = info 274 | 275 | def __str__(self): 276 | """Cloud error message.""" 277 | error_str = u"Type: {}".format(self.type) 278 | error_str += u"\nInfo: {}".format(json.dumps(self.info, indent=4, ensure_ascii=False)) 279 | return error_str 280 | -------------------------------------------------------------------------------- /msrestazure/tools.py: -------------------------------------------------------------------------------- 1 | # -------------------------------------------------------------------------- 2 | # 3 | # Copyright (c) Microsoft Corporation. All rights reserved. 4 | # 5 | # The MIT License (MIT) 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the ""Software""), to 9 | # deal in the Software without restriction, including without limitation the 10 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 11 | # sell copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 22 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 23 | # IN THE SOFTWARE. 24 | # 25 | # -------------------------------------------------------------------------- 26 | 27 | import json 28 | import re 29 | import logging 30 | import time 31 | import uuid 32 | 33 | _LOGGER = logging.getLogger(__name__) 34 | _ARMID_RE = re.compile( 35 | '(?i)/subscriptions/(?P[^/]*)(/resourceGroups/(?P[^/]*))?' 36 | '(/providers/(?P[^/]*)/(?P[^/]*)/(?P[^/]*)(?P.*))?') 37 | 38 | _CHILDREN_RE = re.compile('(?i)(/providers/(?P[^/]*))?/' 39 | '(?P[^/]*)/(?P[^/]*)') 40 | 41 | _ARMNAME_RE = re.compile('^[^<>%&:\\?/]{1,260}$') 42 | 43 | def register_rp_hook(r, *args, **kwargs): 44 | """This is a requests hook to register RP automatically. 45 | 46 | You should not use this command manually, this is added automatically 47 | by the SDK. 48 | 49 | See requests documentation for details of the signature of this function. 50 | http://docs.python-requests.org/en/master/user/advanced/#event-hooks 51 | """ 52 | if r.status_code == 409 and 'msrest' in kwargs: 53 | rp_name = _check_rp_not_registered_err(r) 54 | if rp_name: 55 | session = kwargs['msrest']['session'] 56 | url_prefix = _extract_subscription_url(r.request.url) 57 | if not _register_rp(session, url_prefix, rp_name): 58 | return 59 | req = r.request 60 | # Change the 'x-ms-client-request-id' otherwise the Azure endpoint 61 | # just returns the same 409 payload without looking at the actual query 62 | if 'x-ms-client-request-id' in req.headers: 63 | req.headers['x-ms-client-request-id'] = str(uuid.uuid1()) 64 | return session.send(req) 65 | 66 | def _check_rp_not_registered_err(response): 67 | try: 68 | response = json.loads(response.content.decode()) 69 | if response['error']['code'] == 'MissingSubscriptionRegistration': 70 | match = re.match(r".*'(.*)'", response['error']['message']) 71 | return match.group(1) 72 | except Exception: # pylint: disable=broad-except 73 | pass 74 | return None 75 | 76 | def _extract_subscription_url(url): 77 | """Extract the first part of the URL, just after subscription: 78 | https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/ 79 | """ 80 | match = re.match(r".*/subscriptions/[a-f0-9-]+/", url, re.IGNORECASE) 81 | if not match: 82 | raise ValueError("Unable to extract subscription ID from URL") 83 | return match.group(0) 84 | 85 | def _register_rp(session, url_prefix, rp_name): 86 | """Synchronously register the RP is paremeter. 87 | 88 | Return False if we have a reason to believe this didn't work 89 | """ 90 | post_url = "{}providers/{}/register?api-version=2016-02-01".format(url_prefix, rp_name) 91 | get_url = "{}providers/{}?api-version=2016-02-01".format(url_prefix, rp_name) 92 | _LOGGER.warning("Resource provider '%s' used by this operation is not " 93 | "registered. We are registering for you.", rp_name) 94 | post_response = session.post(post_url) 95 | if post_response.status_code != 200: 96 | _LOGGER.warning("Registration failed. Please register manually.") 97 | return False 98 | 99 | while True: 100 | time.sleep(10) 101 | rp_info = session.get(get_url).json() 102 | if rp_info['registrationState'] == 'Registered': 103 | _LOGGER.warning("Registration succeeded.") 104 | return True 105 | 106 | def parse_resource_id(rid): 107 | """Parses a resource_id into its various parts. 108 | 109 | Returns a dictionary with a single key-value pair, 'name': rid, if invalid resource id. 110 | 111 | :param rid: The resource id being parsed 112 | :type rid: str 113 | :returns: A dictionary with with following key/value pairs (if found): 114 | 115 | - subscription: Subscription id 116 | - resource_group: Name of resource group 117 | - namespace: Namespace for the resource provider (i.e. Microsoft.Compute) 118 | - type: Type of the root resource (i.e. virtualMachines) 119 | - name: Name of the root resource 120 | - child_namespace_{level}: Namespace for the child resoure of that level 121 | - child_type_{level}: Type of the child resource of that level 122 | - child_name_{level}: Name of the child resource of that level 123 | - last_child_num: Level of the last child 124 | - resource_parent: Computed parent in the following pattern: providers/{namespace}\ 125 | /{parent}/{type}/{name} 126 | - resource_namespace: Same as namespace. Note that this may be different than the \ 127 | target resource's namespace. 128 | - resource_type: Type of the target resource (not the parent) 129 | - resource_name: Name of the target resource (not the parent) 130 | 131 | :rtype: dict[str,str] 132 | """ 133 | if not rid: 134 | return {} 135 | match = _ARMID_RE.match(rid) 136 | if match: 137 | result = match.groupdict() 138 | children = _CHILDREN_RE.finditer(result['children'] or '') 139 | count = None 140 | for count, child in enumerate(children): 141 | result.update({ 142 | key + '_%d' % (count + 1): group for key, group in child.groupdict().items()}) 143 | result['last_child_num'] = count + 1 if isinstance(count, int) else None 144 | result = _populate_alternate_kwargs(result) 145 | else: 146 | result = dict(name=rid) 147 | return {key: value for key, value in result.items() if value is not None} 148 | 149 | def _populate_alternate_kwargs(kwargs): 150 | """ Translates the parsed arguments into a format used by generic ARM commands 151 | such as the resource and lock commands. 152 | """ 153 | 154 | resource_namespace = kwargs['namespace'] 155 | resource_type = kwargs.get('child_type_{}'.format(kwargs['last_child_num'])) or kwargs['type'] 156 | resource_name = kwargs.get('child_name_{}'.format(kwargs['last_child_num'])) or kwargs['name'] 157 | 158 | _get_parents_from_parts(kwargs) 159 | kwargs['resource_namespace'] = resource_namespace 160 | kwargs['resource_type'] = resource_type 161 | kwargs['resource_name'] = resource_name 162 | return kwargs 163 | 164 | def _get_parents_from_parts(kwargs): 165 | """ Get the parents given all the children parameters. 166 | """ 167 | parent_builder = [] 168 | if kwargs['last_child_num'] is not None: 169 | parent_builder.append('{type}/{name}/'.format(**kwargs)) 170 | for index in range(1, kwargs['last_child_num']): 171 | child_namespace = kwargs.get('child_namespace_{}'.format(index)) 172 | if child_namespace is not None: 173 | parent_builder.append('providers/{}/'.format(child_namespace)) 174 | kwargs['child_parent_{}'.format(index)] = ''.join(parent_builder) 175 | parent_builder.append( 176 | '{{child_type_{0}}}/{{child_name_{0}}}/' 177 | .format(index).format(**kwargs)) 178 | child_namespace = kwargs.get('child_namespace_{}'.format(kwargs['last_child_num'])) 179 | if child_namespace is not None: 180 | parent_builder.append('providers/{}/'.format(child_namespace)) 181 | kwargs['child_parent_{}'.format(kwargs['last_child_num'])] = ''.join(parent_builder) 182 | kwargs['resource_parent'] = ''.join(parent_builder) if kwargs['name'] else None 183 | return kwargs 184 | 185 | def resource_id(**kwargs): 186 | """Create a valid resource id string from the given parts. 187 | 188 | This method builds the resource id from the left until the next required id parameter 189 | to be appended is not found. It then returns the built up id. 190 | 191 | :param dict kwargs: The keyword arguments that will make up the id. 192 | 193 | The method accepts the following keyword arguments: 194 | - subscription (required): Subscription id 195 | - resource_group: Name of resource group 196 | - namespace: Namespace for the resource provider (i.e. Microsoft.Compute) 197 | - type: Type of the resource (i.e. virtualMachines) 198 | - name: Name of the resource (or parent if child_name is also \ 199 | specified) 200 | - child_namespace_{level}: Namespace for the child resoure of that level (optional) 201 | - child_type_{level}: Type of the child resource of that level 202 | - child_name_{level}: Name of the child resource of that level 203 | 204 | :returns: A resource id built from the given arguments. 205 | :rtype: str 206 | """ 207 | kwargs = {k: v for k, v in kwargs.items() if v is not None} 208 | rid_builder = ['/subscriptions/{subscription}'.format(**kwargs)] 209 | try: 210 | try: 211 | rid_builder.append('resourceGroups/{resource_group}'.format(**kwargs)) 212 | except KeyError: 213 | pass 214 | rid_builder.append('providers/{namespace}'.format(**kwargs)) 215 | rid_builder.append('{type}/{name}'.format(**kwargs)) 216 | count = 1 217 | while True: 218 | try: 219 | rid_builder.append('providers/{{child_namespace_{}}}' 220 | .format(count).format(**kwargs)) 221 | except KeyError: 222 | pass 223 | rid_builder.append('{{child_type_{0}}}/{{child_name_{0}}}' 224 | .format(count).format(**kwargs)) 225 | count += 1 226 | except KeyError: 227 | pass 228 | return '/'.join(rid_builder) 229 | 230 | def is_valid_resource_id(rid, exception_type=None): 231 | """Validates the given resource id. 232 | 233 | :param rid: The resource id being validated. 234 | :type rid: str 235 | :param exception_type: Raises this Exception if invalid. 236 | :type exception_type: :class:`Exception` 237 | :returns: A boolean describing whether the id is valid. 238 | :rtype: bool 239 | """ 240 | is_valid = False 241 | try: 242 | is_valid = rid and resource_id(**parse_resource_id(rid)).lower() == rid.lower() 243 | except KeyError: 244 | pass 245 | if not is_valid and exception_type: 246 | raise exception_type() 247 | return is_valid 248 | 249 | 250 | def is_valid_resource_name(rname, exception_type=None): 251 | """Validates the given resource name to ARM guidelines, individual services may be more restrictive. 252 | 253 | :param rname: The resource name being validated. 254 | :type rname: str 255 | :param exception_type: Raises this Exception if invalid. 256 | :type exception_type: :class:`Exception` 257 | :returns: A boolean describing whether the name is valid. 258 | :rtype: bool 259 | """ 260 | 261 | match = _ARMNAME_RE.match(rname) 262 | 263 | if match: 264 | return True 265 | if exception_type: 266 | raise exception_type() 267 | return False 268 | -------------------------------------------------------------------------------- /msrestazure/azure_cloud.py: -------------------------------------------------------------------------------- 1 | # -------------------------------------------------------------------------- 2 | # 3 | # Copyright (c) Microsoft Corporation. All rights reserved. 4 | # 5 | # The MIT License (MIT) 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the ""Software""), to 9 | # deal in the Software without restriction, including without limitation the 10 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 11 | # sell copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 22 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 23 | # IN THE SOFTWARE. 24 | # 25 | # -------------------------------------------------------------------------- 26 | import os 27 | import logging 28 | from pprint import pformat 29 | 30 | 31 | _LOGGER = logging.getLogger(__name__) 32 | 33 | 34 | # The exact API version doesn't matter too much right now. It just has to be YYYY-MM-DD format. 35 | METADATA_ENDPOINT_SUFFIX = '/metadata/endpoints?api-version=2015-01-01' 36 | 37 | class CloudEndpointNotSetException(Exception): 38 | pass 39 | 40 | 41 | class CloudSuffixNotSetException(Exception): 42 | pass 43 | 44 | 45 | class MetadataEndpointError(Exception): 46 | pass 47 | 48 | 49 | class CloudEndpoints(object): # pylint: disable=too-few-public-methods,too-many-instance-attributes 50 | 51 | def __init__(self, 52 | management=None, 53 | resource_manager=None, 54 | sql_management=None, 55 | batch_resource_id=None, 56 | gallery=None, 57 | active_directory=None, 58 | active_directory_resource_id=None, 59 | active_directory_graph_resource_id=None, 60 | microsoft_graph_resource_id=None): 61 | # Attribute names are significant. They are used when storing/retrieving clouds from config 62 | self.management = management 63 | self.resource_manager = resource_manager 64 | self.sql_management = sql_management 65 | self.batch_resource_id = batch_resource_id 66 | self.gallery = gallery 67 | self.active_directory = active_directory 68 | self.active_directory_resource_id = active_directory_resource_id 69 | self.active_directory_graph_resource_id = active_directory_graph_resource_id 70 | self.microsoft_graph_resource_id = microsoft_graph_resource_id 71 | 72 | def has_endpoint_set(self, endpoint_name): 73 | try: 74 | # Can't simply use hasattr here as we override __getattribute__ below. 75 | # Python 3 hasattr() only returns False if an AttributeError is raised but we raise 76 | # CloudEndpointNotSetException. This exception is not a subclass of AttributeError. 77 | getattr(self, endpoint_name) 78 | return True 79 | except Exception: # pylint: disable=broad-except 80 | return False 81 | 82 | def __getattribute__(self, name): 83 | val = object.__getattribute__(self, name) 84 | if val is None: 85 | raise CloudEndpointNotSetException("The endpoint '{}' for this cloud " 86 | "is not set but is used.".format(name)) 87 | return val 88 | 89 | 90 | class CloudSuffixes(object): # pylint: disable=too-few-public-methods 91 | 92 | def __init__(self, 93 | storage_endpoint=None, 94 | keyvault_dns=None, 95 | sql_server_hostname=None, 96 | azure_datalake_store_file_system_endpoint=None, 97 | azure_datalake_analytics_catalog_and_job_endpoint=None): 98 | # Attribute names are significant. They are used when storing/retrieving clouds from config 99 | self.storage_endpoint = storage_endpoint 100 | self.keyvault_dns = keyvault_dns 101 | self.sql_server_hostname = sql_server_hostname 102 | self.azure_datalake_store_file_system_endpoint = azure_datalake_store_file_system_endpoint 103 | self.azure_datalake_analytics_catalog_and_job_endpoint = azure_datalake_analytics_catalog_and_job_endpoint # pylint: disable=line-too-long 104 | 105 | def __getattribute__(self, name): 106 | val = object.__getattribute__(self, name) 107 | if val is None: 108 | raise CloudSuffixNotSetException("The suffix '{}' for this cloud " 109 | "is not set but is used.".format(name)) 110 | return val 111 | 112 | 113 | class Cloud(object): # pylint: disable=too-few-public-methods 114 | """ Represents an Azure Cloud instance """ 115 | 116 | def __init__(self, 117 | name, 118 | endpoints=None, 119 | suffixes=None): 120 | self.name = name 121 | self.endpoints = endpoints or CloudEndpoints() 122 | self.suffixes = suffixes or CloudSuffixes() 123 | 124 | def __str__(self): 125 | o = { 126 | 'name': self.name, 127 | 'endpoints': vars(self.endpoints), 128 | 'suffixes': vars(self.suffixes), 129 | } 130 | return pformat(o) 131 | 132 | 133 | AZURE_PUBLIC_CLOUD = Cloud( 134 | 'AzureCloud', 135 | endpoints=CloudEndpoints( 136 | management='https://management.core.windows.net/', 137 | resource_manager='https://management.azure.com/', 138 | sql_management='https://management.core.windows.net:8443/', 139 | batch_resource_id='https://batch.core.windows.net/', 140 | gallery='https://gallery.azure.com/', 141 | active_directory='https://login.microsoftonline.com', 142 | active_directory_resource_id='https://management.core.windows.net/', 143 | active_directory_graph_resource_id='https://graph.windows.net/', 144 | microsoft_graph_resource_id='https://graph.microsoft.com/'), 145 | suffixes=CloudSuffixes( 146 | storage_endpoint='core.windows.net', 147 | keyvault_dns='.vault.azure.net', 148 | sql_server_hostname='.database.windows.net', 149 | azure_datalake_store_file_system_endpoint='azuredatalakestore.net', 150 | azure_datalake_analytics_catalog_and_job_endpoint='azuredatalakeanalytics.net')) 151 | 152 | AZURE_CHINA_CLOUD = Cloud( 153 | 'AzureChinaCloud', 154 | endpoints=CloudEndpoints( 155 | management='https://management.core.chinacloudapi.cn/', 156 | resource_manager='https://management.chinacloudapi.cn', 157 | sql_management='https://management.core.chinacloudapi.cn:8443/', 158 | batch_resource_id='https://batch.chinacloudapi.cn/', 159 | gallery='https://gallery.chinacloudapi.cn/', 160 | active_directory='https://login.chinacloudapi.cn', 161 | active_directory_resource_id='https://management.core.chinacloudapi.cn/', 162 | active_directory_graph_resource_id='https://graph.chinacloudapi.cn/', 163 | microsoft_graph_resource_id='https://microsoftgraph.chinacloudapi.cn/'), 164 | suffixes=CloudSuffixes( 165 | storage_endpoint='core.chinacloudapi.cn', 166 | keyvault_dns='.vault.azure.cn', 167 | sql_server_hostname='.database.chinacloudapi.cn')) 168 | 169 | AZURE_US_GOV_CLOUD = Cloud( 170 | 'AzureUSGovernment', 171 | endpoints=CloudEndpoints( 172 | management='https://management.core.usgovcloudapi.net/', 173 | resource_manager='https://management.usgovcloudapi.net/', 174 | sql_management='https://management.core.usgovcloudapi.net:8443/', 175 | batch_resource_id='https://batch.core.usgovcloudapi.net/', 176 | gallery='https://gallery.usgovcloudapi.net/', 177 | active_directory='https://login.microsoftonline.us', 178 | active_directory_resource_id='https://management.core.usgovcloudapi.net/', 179 | active_directory_graph_resource_id='https://graph.windows.net/', 180 | microsoft_graph_resource_id='https://graph.microsoft.us/'), 181 | suffixes=CloudSuffixes( 182 | storage_endpoint='core.usgovcloudapi.net', 183 | keyvault_dns='.vault.usgovcloudapi.net', 184 | sql_server_hostname='.database.usgovcloudapi.net')) 185 | 186 | AZURE_GERMAN_CLOUD = Cloud( 187 | 'AzureGermanCloud', 188 | endpoints=CloudEndpoints( 189 | management='https://management.core.cloudapi.de/', 190 | resource_manager='https://management.microsoftazure.de', 191 | sql_management='https://management.core.cloudapi.de:8443/', 192 | batch_resource_id='https://batch.cloudapi.de/', 193 | gallery='https://gallery.cloudapi.de/', 194 | active_directory='https://login.microsoftonline.de', 195 | active_directory_resource_id='https://management.core.cloudapi.de/', 196 | active_directory_graph_resource_id='https://graph.cloudapi.de/', 197 | microsoft_graph_resource_id='https://graph.microsoft.de/'), 198 | suffixes=CloudSuffixes( 199 | storage_endpoint='core.cloudapi.de', 200 | keyvault_dns='.vault.microsoftazure.de', 201 | sql_server_hostname='.database.cloudapi.de')) 202 | 203 | 204 | def _populate_from_metadata_endpoint(cloud, arm_endpoint, session=None): 205 | endpoints_in_metadata = ['active_directory_graph_resource_id', 206 | 'active_directory_resource_id', 'active_directory'] 207 | if not arm_endpoint or all([cloud.endpoints.has_endpoint_set(n) for n in endpoints_in_metadata]): 208 | return 209 | try: 210 | error_msg_fmt = "Unable to get endpoints from the cloud.\n{}" 211 | import requests 212 | session = requests.Session() if session is None else session 213 | metadata_endpoint = arm_endpoint + METADATA_ENDPOINT_SUFFIX 214 | response = session.get(metadata_endpoint) 215 | if response.status_code == 200: 216 | metadata = response.json() 217 | if not cloud.endpoints.has_endpoint_set('gallery'): 218 | setattr(cloud.endpoints, 'gallery', metadata.get('galleryEndpoint')) 219 | if not cloud.endpoints.has_endpoint_set('active_directory_graph_resource_id'): 220 | setattr(cloud.endpoints, 'active_directory_graph_resource_id', metadata.get('graphEndpoint')) 221 | if not cloud.endpoints.has_endpoint_set('active_directory'): 222 | setattr(cloud.endpoints, 'active_directory', metadata['authentication'].get('loginEndpoint')) 223 | if not cloud.endpoints.has_endpoint_set('active_directory_resource_id'): 224 | setattr(cloud.endpoints, 'active_directory_resource_id', metadata['authentication']['audiences'][0]) 225 | else: 226 | msg = 'Server returned status code {} for {}'.format(response.status_code, metadata_endpoint) 227 | raise MetadataEndpointError(error_msg_fmt.format(msg)) 228 | except (requests.exceptions.ConnectionError, requests.exceptions.HTTPError) as err: 229 | msg = 'Please ensure you have network connection. Error detail: {}'.format(str(err)) 230 | raise MetadataEndpointError(error_msg_fmt.format(msg)) 231 | except ValueError as err: 232 | msg = 'Response body does not contain valid json. Error detail: {}'.format(str(err)) 233 | raise MetadataEndpointError(error_msg_fmt.format(msg)) 234 | 235 | def get_cloud_from_metadata_endpoint(arm_endpoint, name=None, session=None): 236 | """Get a Cloud object from an ARM endpoint. 237 | 238 | .. versionadded:: 0.4.11 239 | 240 | :Example: 241 | 242 | .. code:: python 243 | 244 | get_cloud_from_metadata_endpoint(https://management.azure.com/, "Public Azure") 245 | 246 | :param str arm_endpoint: The ARM management endpoint 247 | :param str name: An optional name for the Cloud object. Otherwise it's the ARM endpoint 248 | :params requests.Session session: A requests session object if you need to configure proxy, cert, etc. 249 | :rtype Cloud: 250 | :returns: a Cloud object 251 | :raises: MetadataEndpointError if unable to build the Cloud object 252 | """ 253 | cloud = Cloud(name or arm_endpoint) 254 | cloud.endpoints.management = arm_endpoint 255 | cloud.endpoints.resource_manager = arm_endpoint 256 | _populate_from_metadata_endpoint(cloud, arm_endpoint, session) 257 | return cloud 258 | -------------------------------------------------------------------------------- /tests/test_exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | #-------------------------------------------------------------------------- 3 | # 4 | # Copyright (c) Microsoft Corporation. All rights reserved. 5 | # 6 | # The MIT License (MIT) 7 | # 8 | # Permission is hereby granted, free of charge, to any person obtaining a copy 9 | # of this software and associated documentation files (the ""Software""), to deal 10 | # in the Software without restriction, including without limitation the rights 11 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | # copies of the Software, and to permit persons to whom the Software is 13 | # furnished to do so, subject to the following conditions: 14 | # 15 | # The above copyright notice and this permission notice shall be included in 16 | # all copies or substantial portions of the Software. 17 | # 18 | # THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24 | # THE SOFTWARE. 25 | # 26 | #-------------------------------------------------------------------------- 27 | 28 | import json 29 | import unittest 30 | 31 | from requests import Response, RequestException 32 | 33 | from msrest import Deserializer, Configuration 34 | from msrestazure.azure_exceptions import CloudErrorData, CloudError, TypedErrorInfo 35 | 36 | 37 | class TestCloudException(unittest.TestCase): 38 | 39 | def setUp(self): 40 | self.cfg = Configuration("https://my_endpoint.com") 41 | self._d = Deserializer() 42 | self._d.dependencies = { 43 | 'CloudErrorData': CloudErrorData, 44 | 'TypedErrorInfo': TypedErrorInfo 45 | } 46 | return super(TestCloudException, self).setUp() 47 | 48 | def test_cloud_exception(self): 49 | 50 | message = { 51 | 'code': '500', 52 | 'message': 'Bad Request', 53 | 'values': {'invalid_attribute':'data'} 54 | } 55 | 56 | cloud_exp = self._d(CloudErrorData(), message) 57 | self.assertEqual(cloud_exp.message, 'Bad Request') 58 | self.assertEqual(cloud_exp.error, '500') 59 | self.assertEqual(cloud_exp.data['invalid_attribute'], 'data') 60 | 61 | message = { 62 | 'code': '500', 63 | 'message': {'value': 'Bad Request\nRequest:34875\nTime:1999-12-31T23:59:59-23:59'}, 64 | 'values': {'invalid_attribute':'data'} 65 | } 66 | 67 | cloud_exp = self._d(CloudErrorData(), message) 68 | self.assertEqual(cloud_exp.message, 'Bad Request') 69 | self.assertEqual(cloud_exp.error, '500') 70 | self.assertEqual(cloud_exp.data['invalid_attribute'], 'data') 71 | 72 | message = { 73 | 'code': '500', 74 | 'message': {'value': 'Bad Request\nRequest:34875'}, 75 | 'values': {'invalid_attribute':'data'} 76 | } 77 | 78 | cloud_exp = self._d(CloudErrorData(), message) 79 | self.assertEqual(cloud_exp.message, 'Bad Request') 80 | self.assertEqual(cloud_exp.request_id, '34875') 81 | self.assertEqual(cloud_exp.error, '500') 82 | self.assertEqual(cloud_exp.data['invalid_attribute'], 'data') 83 | 84 | message = {} 85 | cloud_exp = self._d(CloudErrorData(), message) 86 | self.assertEqual(cloud_exp.message, None) 87 | self.assertEqual(cloud_exp.error, None) 88 | 89 | message = ('{\r\n "odata.metadata":"https://account.region.batch.azure.com/$metadata#' 90 | 'Microsoft.Azure.Batch.Protocol.Entities.Container.errors/@Element","code":' 91 | '"InvalidHeaderValue","message":{\r\n "lang":"en-US","value":"The value ' 92 | 'for one of the HTTP headers is not in the correct format.\\nRequestId:5f4c1f05-' 93 | '603a-4495-8e80-01f776310bbd\\nTime:2016-01-04T22:12:33.9245931Z"\r\n },' 94 | '"values":[\r\n {\r\n "key":"HeaderName","value":"Content-Type"\r\n }' 95 | ',{\r\n "key":"HeaderValue","value":"application/json; odata=minimalmetadata;' 96 | ' charset=utf-8"\r\n }\r\n ]\r\n}') 97 | message = json.loads(message) 98 | cloud_exp = self._d(CloudErrorData(), message) 99 | self.assertEqual( 100 | cloud_exp.message, 101 | "The value for one of the HTTP headers is not in the correct format.") 102 | 103 | message = { 104 | "code": "BadArgument", 105 | "message": "The provided database 'foo' has an invalid username.", 106 | "target": "query", 107 | "details": [ 108 | { 109 | "code": "301", 110 | "target": "$search", 111 | "message": "$search query option not supported", 112 | } 113 | ], 114 | "innererror": { 115 | "customKey": "customValue" 116 | }, 117 | "additionalInfo": [ 118 | { 119 | "type": "SomeErrorType", 120 | "info": { 121 | "customKey": "customValue" 122 | } 123 | } 124 | ] 125 | } 126 | cloud_exp = self._d(CloudErrorData(), message) 127 | self.assertEqual(cloud_exp.target, 'query') 128 | self.assertEqual(cloud_exp.details[0].target, '$search') 129 | self.assertEqual(cloud_exp.innererror['customKey'], 'customValue') 130 | self.assertEqual(cloud_exp.additionalInfo[0].type, 'SomeErrorType') 131 | self.assertEqual(cloud_exp.additionalInfo[0].info['customKey'], 'customValue') 132 | self.assertIn('customValue', str(cloud_exp)) 133 | 134 | message = { 135 | "code": "BadArgument", 136 | "message": "The provided database 'foo' has an invalid username.", 137 | "target": "query", 138 | "details": [ 139 | { 140 | "code": "301", 141 | "target": "$search", 142 | "message": "$search query option not supported", 143 | "additionalInfo": [ 144 | { 145 | "type": "PolicyViolation", 146 | "info": { 147 | "policyDefinitionDisplayName": "Allowed locations", 148 | "policyAssignmentParameters": { 149 | "listOfAllowedLocations": { 150 | "value": [ 151 | "westus" 152 | ] 153 | } 154 | } 155 | } 156 | } 157 | ] 158 | } 159 | ], 160 | "additionalInfo": [ 161 | { 162 | "type": "SomeErrorType", 163 | "info": { 164 | "customKey": "customValue" 165 | } 166 | } 167 | ] 168 | } 169 | cloud_exp = self._d(CloudErrorData(), message) 170 | self.assertEqual(cloud_exp.target, 'query') 171 | self.assertEqual(cloud_exp.details[0].target, '$search') 172 | self.assertEqual(cloud_exp.additionalInfo[0].type, 'SomeErrorType') 173 | self.assertEqual(cloud_exp.additionalInfo[0].info['customKey'], 'customValue') 174 | self.assertEqual(cloud_exp.details[0].additionalInfo[0].type, 'PolicyViolation') 175 | self.assertEqual(cloud_exp.details[0].additionalInfo[0].info['policyDefinitionDisplayName'], 'Allowed locations') 176 | self.assertEqual(cloud_exp.details[0].additionalInfo[0].info['policyAssignmentParameters']['listOfAllowedLocations']['value'][0], 'westus') 177 | self.assertIn('customValue', str(cloud_exp)) 178 | 179 | 180 | def test_cloud_error(self): 181 | 182 | response = Response() 183 | response._content = br'{"real": true}' # Has to be valid bytes JSON 184 | response._content_consumed = True 185 | response.status_code = 400 186 | response.headers = {"content-type": "application/json; charset=utf8"} 187 | response.reason = 'BadRequest' 188 | 189 | message = { 'error': { 190 | 'code': '500', 191 | 'message': {'value': 'Bad Request\nRequest:34875\nTime:1999-12-31T23:59:59-23:59'}, 192 | 'values': {'invalid_attribute':'data'} 193 | }} 194 | 195 | response._content = json.dumps(message).encode("utf-8") 196 | 197 | error = CloudError(response) 198 | self.assertEqual(error.message, 'Bad Request') 199 | self.assertEqual(error.status_code, 400) 200 | self.assertIsInstance(error.response, Response) 201 | self.assertIsInstance(error.error, CloudErrorData) 202 | 203 | error = CloudError(response, "Request failed with bad status") 204 | self.assertEqual(error.message, "Request failed with bad status") 205 | self.assertEqual(error.status_code, 400) 206 | self.assertIsInstance(error.error, Response) 207 | 208 | message = { 'error': { 209 | 'code': '500', 210 | 'message': u"ééééé", 211 | }} 212 | response._content = json.dumps(message).encode("utf-8") 213 | error = CloudError(response) 214 | try: # Python 2 215 | assert u"ééééé" in unicode(error) 216 | assert u"ééééé".encode("utf-8") in str(error) 217 | except NameError: # Python 3 218 | assert "ééééé" in str(error) 219 | 220 | response._content = b"{{" 221 | error = CloudError(response) 222 | self.assertIn("None", error.message) 223 | 224 | response._content = json.dumps({'message':'server error'}).encode("utf-8") 225 | error = CloudError(response) 226 | self.assertTrue("server error" in error.message) 227 | self.assertEqual(error.status_code, 400) 228 | 229 | response._content = b"{{" 230 | response.reason = "FAILED!" 231 | error = CloudError(response) 232 | self.assertTrue("FAILED!" in error.message) 233 | self.assertIsInstance(error.error, RequestException) 234 | 235 | response.reason = 'BadRequest' 236 | 237 | response._content = b'{\r\n "odata.metadata":"https://account.region.batch.azure.com/$metadata#Microsoft.Azure.Batch.Protocol.Entities.Container.errors/@Element","code":"InvalidHeaderValue","message":{\r\n "lang":"en-US","value":"The value for one of the HTTP headers is not in the correct format.\\nRequestId:5f4c1f05-603a-4495-8e80-01f776310bbd\\nTime:2016-01-04T22:12:33.9245931Z"\r\n },"values":[\r\n {\r\n "key":"HeaderName","value":"Content-Type"\r\n },{\r\n "key":"HeaderValue","value":"application/json; odata=minimalmetadata; charset=utf-8"\r\n }\r\n ]\r\n}' 238 | error = CloudError(response) 239 | self.assertIn("The value for one of the HTTP headers is not in the correct format", error.message) 240 | 241 | response._content = b'{"error":{"code":"Conflict","message":"The maximum number of Free ServerFarms allowed in a Subscription is 10.","target":null,"details":[{"message":"The maximum number of Free ServerFarms allowed in a Subscription is 10."},{"code":"Conflict"},{"errorentity":{"code":"Conflict","message":"The maximum number of Free ServerFarms allowed in a Subscription is 10.","extendedCode":"59301","messageTemplate":"The maximum number of {0} ServerFarms allowed in a Subscription is {1}.","parameters":["Free","10"],"innerErrors":null}}],"innererror":null}}' 242 | error = CloudError(response) 243 | self.assertIsInstance(error.error, CloudErrorData) 244 | self.assertEqual(error.error.error, "Conflict") 245 | 246 | response._content = json.dumps({ 247 | "error": { 248 | "code": "BadArgument", 249 | "message": "The provided database 'foo' has an invalid username.", 250 | "target": "query", 251 | "details": [ 252 | { 253 | "code": "301", 254 | "target": "$search", 255 | "message": "$search query option not supported", 256 | } 257 | ] 258 | }}).encode('utf-8') 259 | error = CloudError(response) 260 | self.assertIsInstance(error.error, CloudErrorData) 261 | self.assertEqual(error.error.error, "BadArgument") 262 | 263 | # See https://github.com/Azure/msrestazure-for-python/issues/54 264 | response._content = b'"{\\"error\\": {\\"code\\": \\"ResourceGroupNotFound\\", \\"message\\": \\"Resource group \'res_grp\' could not be found.\\"}}"' 265 | error = CloudError(response) 266 | self.assertIn(response.text, error.message) 267 | 268 | response._content = json.dumps({ 269 | "error": { 270 | "code": "InvalidTemplateDeployment", 271 | "message": "The template deployment failed because of policy violation. Please see details for more information.", 272 | "details": [{ 273 | "code": "RequestDisallowedByPolicy", 274 | "target": "vm1", 275 | "message": "Resource 'vm1' was disallowed by policy. Policy identifiers: '[{\"policyAssignment\":{\"name\":\"Allowed virtual machine SKUs\",\"id\":\"/subscriptions/0b1f6471-1bf0-4dda-aec3-cb9272f09590/resourceGroups/fytest/providers/Microsoft.Authorization/policyAssignments/9c95e7fe8227466b82f48228\"},\"policyDefinition\":{\"name\":\"Allowed virtual machine SKUs\",\"id\":\"/providers/Microsoft.Authorization/policyDefinitions/cccc23c7-8427-4f53-ad12-b6a63eb452b3\"}}]'.", 276 | "additionalInfo": [{ 277 | "type": "PolicyViolation", 278 | "info": { 279 | "policyDefinitionDisplayName": "Allowed virtual machine SKUs", 280 | "evaluationDetails": { 281 | "evaluatedExpressions": [{ 282 | "result": "True", 283 | "expression": "type", 284 | "path": "type", 285 | "expressionValue": "Microsoft.Compute/virtualMachines", 286 | "targetValue": "Microsoft.Compute/virtualMachines", 287 | "operator": "Equals" 288 | }, { 289 | "result": "False", 290 | "expression": "Microsoft.Compute/virtualMachines/sku.name", 291 | "path": "properties.hardwareProfile.vmSize", 292 | "expressionValue": "Standard_DS1_v2", 293 | "targetValue": ["Basic_A0"], 294 | "operator": "In" 295 | }] 296 | }, 297 | "policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/cccc23c7-8427-4f53-ad12-b6a63eb452b3", 298 | "policyDefinitionName": "cccc23c7-8427-4f53-ad12-b6a63eb452b3", 299 | "policyDefinitionEffect": "Deny", 300 | "policyAssignmentId": "/subscriptions/0b1f6471-1bf0-4dda-aec3-cb9272f09590/resourceGroups/fytest/providers/Microsoft.Authorization/policyAssignments/9c95e7fe8227466b82f48228", 301 | "policyAssignmentName": "9c95e7fe8227466b82f48228", 302 | "policyAssignmentDisplayName": "Allowed virtual machine SKUs", 303 | "policyAssignmentScope": "/subscriptions/0b1f6471-1bf0-4dda-aec3-cb9272f09590/resourceGroups/fytest", 304 | "policyAssignmentParameters": { 305 | "listOfAllowedSKUs": { 306 | "value": ["Basic_A0"] 307 | } 308 | } 309 | } 310 | }] 311 | }] 312 | } 313 | }).encode('utf-8') 314 | 315 | error = CloudError(response) 316 | assert error.message == "The template deployment failed because of policy violation. Please see details for more information." 317 | 318 | if __name__ == '__main__': 319 | unittest.main() -------------------------------------------------------------------------------- /tests/asynctests/test_async_arm_polling.py: -------------------------------------------------------------------------------- 1 | #-------------------------------------------------------------------------- 2 | # 3 | # Copyright (c) Microsoft Corporation. All rights reserved. 4 | # 5 | # The MIT License (MIT) 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the ""Software""), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | # THE SOFTWARE. 24 | # 25 | #-------------------------------------------------------------------------- 26 | 27 | import json 28 | import re 29 | import types 30 | import unittest 31 | try: 32 | from unittest import mock 33 | except ImportError: 34 | import mock 35 | 36 | import pytest 37 | 38 | from requests import Request, Response 39 | 40 | from msrest import Deserializer, Configuration 41 | from msrest.async_client import ServiceClientAsync 42 | from msrest.exceptions import DeserializationError 43 | from msrest.polling import async_poller 44 | 45 | from msrestazure.azure_exceptions import CloudError 46 | from msrestazure.polling.async_arm_polling import ( 47 | AsyncARMPolling, 48 | ) 49 | from msrestazure.polling.arm_polling import ( 50 | LongRunningOperation, 51 | BadStatus 52 | ) 53 | 54 | class SimpleResource: 55 | """An implementation of Python 3 SimpleNamespace. 56 | Used to deserialize resource objects from response bodies where 57 | no particular object type has been specified. 58 | """ 59 | 60 | def __init__(self, **kwargs): 61 | self.__dict__.update(kwargs) 62 | 63 | def __repr__(self): 64 | keys = sorted(self.__dict__) 65 | items = ("{}={!r}".format(k, self.__dict__[k]) for k in keys) 66 | return "{}({})".format(type(self).__name__, ", ".join(items)) 67 | 68 | def __eq__(self, other): 69 | return self.__dict__ == other.__dict__ 70 | 71 | class BadEndpointError(Exception): 72 | pass 73 | 74 | TEST_NAME = 'foo' 75 | RESPONSE_BODY = {'properties':{'provisioningState': 'InProgress'}} 76 | ASYNC_BODY = json.dumps({ 'status': 'Succeeded' }) 77 | ASYNC_URL = 'http://dummyurlFromAzureAsyncOPHeader_Return200' 78 | LOCATION_BODY = json.dumps({ 'name': TEST_NAME }) 79 | LOCATION_URL = 'http://dummyurlurlFromLocationHeader_Return200' 80 | RESOURCE_BODY = json.dumps({ 'name': TEST_NAME }) 81 | RESOURCE_URL = 'http://subscriptions/sub1/resourcegroups/g1/resourcetype1/resource1' 82 | ERROR = 'http://dummyurl_ReturnError' 83 | POLLING_STATUS = 200 84 | 85 | CLIENT = ServiceClientAsync(Configuration("http://example.org")) 86 | async def mock_send(client_self, request, *, stream): 87 | return TestArmPolling.mock_update(request.url) 88 | CLIENT.async_send = types.MethodType(mock_send, CLIENT) 89 | 90 | 91 | class TestArmPolling(object): 92 | 93 | convert = re.compile('([a-z0-9])([A-Z])') 94 | 95 | @staticmethod 96 | def mock_send(method, status, headers, body=None): 97 | response = mock.create_autospec(Response) 98 | response.request = mock.create_autospec(Request) 99 | response.request.method = method 100 | response.request.url = RESOURCE_URL 101 | response.request.headers = { 102 | 'x-ms-client-request-id': '67f4dd4e-6262-45e1-8bed-5c45cf23b6d9' 103 | } 104 | response.status_code = status 105 | response.headers = headers 106 | response.headers.update({"content-type": "application/json; charset=utf8"}) 107 | content = body if body is not None else RESPONSE_BODY 108 | response.text = json.dumps(content) 109 | response.json = lambda: json.loads(response.text) 110 | return response 111 | 112 | @staticmethod 113 | def mock_update(url, headers=None): 114 | response = mock.create_autospec(Response) 115 | response.request = mock.create_autospec(Request) 116 | response.request.method = 'GET' 117 | response.headers = headers or {} 118 | response.headers.update({"content-type": "application/json; charset=utf8"}) 119 | 120 | if url == ASYNC_URL: 121 | response.request.url = url 122 | response.status_code = POLLING_STATUS 123 | response.text = ASYNC_BODY 124 | response.randomFieldFromPollAsyncOpHeader = None 125 | 126 | elif url == LOCATION_URL: 127 | response.request.url = url 128 | response.status_code = POLLING_STATUS 129 | response.text = LOCATION_BODY 130 | response.randomFieldFromPollLocationHeader = None 131 | 132 | elif url == ERROR: 133 | raise BadEndpointError("boom") 134 | 135 | elif url == RESOURCE_URL: 136 | response.request.url = url 137 | response.status_code = POLLING_STATUS 138 | response.text = RESOURCE_BODY 139 | 140 | else: 141 | raise Exception('URL does not match') 142 | response.json = lambda: json.loads(response.text) 143 | return response 144 | 145 | @staticmethod 146 | def mock_outputs(response): 147 | body = response.json() 148 | body = {TestArmPolling.convert.sub(r'\1_\2', k).lower(): v 149 | for k, v in body.items()} 150 | properties = body.setdefault('properties', {}) 151 | if 'name' in body: 152 | properties['name'] = body['name'] 153 | if properties: 154 | properties = {TestArmPolling.convert.sub(r'\1_\2', k).lower(): v 155 | for k, v in properties.items()} 156 | del body['properties'] 157 | body.update(properties) 158 | resource = SimpleResource(**body) 159 | else: 160 | raise DeserializationError("Impossible to deserialize") 161 | resource = SimpleResource(**body) 162 | return resource 163 | 164 | @pytest.mark.asyncio 165 | async def test_long_running_put(): 166 | #TODO: Test custom header field 167 | 168 | # Test throw on non LRO related status code 169 | response = TestArmPolling.mock_send('PUT', 1000, {}) 170 | op = LongRunningOperation(response, lambda x:None) 171 | with pytest.raises(BadStatus): 172 | op.set_initial_status(response) 173 | with pytest.raises(CloudError): 174 | await async_poller(CLIENT, response, 175 | TestArmPolling.mock_outputs, 176 | AsyncARMPolling(0)) 177 | 178 | # Test with no polling necessary 179 | response_body = { 180 | 'properties':{'provisioningState': 'Succeeded'}, 181 | 'name': TEST_NAME 182 | } 183 | response = TestArmPolling.mock_send( 184 | 'PUT', 201, 185 | {}, response_body 186 | ) 187 | def no_update_allowed(url, headers=None): 188 | raise ValueError("Should not try to update") 189 | polling_method = AsyncARMPolling(0) 190 | poll = await async_poller(CLIENT, response, 191 | TestArmPolling.mock_outputs, 192 | polling_method 193 | ) 194 | assert poll.name == TEST_NAME 195 | assert not hasattr(polling_method._response, 'randomFieldFromPollAsyncOpHeader') 196 | 197 | # Test polling from azure-asyncoperation header 198 | response = TestArmPolling.mock_send( 199 | 'PUT', 201, 200 | {'azure-asyncoperation': ASYNC_URL}) 201 | polling_method = AsyncARMPolling(0) 202 | poll = await async_poller(CLIENT, response, 203 | TestArmPolling.mock_outputs, 204 | polling_method) 205 | assert poll.name == TEST_NAME 206 | assert not hasattr(polling_method._response, 'randomFieldFromPollAsyncOpHeader') 207 | 208 | # Test polling location header 209 | response = TestArmPolling.mock_send( 210 | 'PUT', 201, 211 | {'location': LOCATION_URL}) 212 | polling_method = AsyncARMPolling(0) 213 | poll = await async_poller(CLIENT, response, 214 | TestArmPolling.mock_outputs, 215 | polling_method) 216 | assert poll.name == TEST_NAME 217 | assert polling_method._response.randomFieldFromPollLocationHeader is None 218 | 219 | # Test polling initial payload invalid (SQLDb) 220 | response_body = {} # Empty will raise 221 | response = TestArmPolling.mock_send( 222 | 'PUT', 201, 223 | {'location': LOCATION_URL}, response_body) 224 | polling_method = AsyncARMPolling(0) 225 | poll = await async_poller(CLIENT, response, 226 | TestArmPolling.mock_outputs, 227 | polling_method) 228 | assert poll.name == TEST_NAME 229 | assert polling_method._response.randomFieldFromPollLocationHeader is None 230 | 231 | # Test fail to poll from azure-asyncoperation header 232 | response = TestArmPolling.mock_send( 233 | 'PUT', 201, 234 | {'azure-asyncoperation': ERROR}) 235 | with pytest.raises(BadEndpointError): 236 | poll = await async_poller(CLIENT, response, 237 | TestArmPolling.mock_outputs, 238 | AsyncARMPolling(0)) 239 | 240 | # Test fail to poll from location header 241 | response = TestArmPolling.mock_send( 242 | 'PUT', 201, 243 | {'location': ERROR}) 244 | with pytest.raises(BadEndpointError): 245 | poll = await async_poller(CLIENT, response, 246 | TestArmPolling.mock_outputs, 247 | AsyncARMPolling(0)) 248 | 249 | @pytest.mark.asyncio 250 | async def test_long_running_patch(): 251 | 252 | # Test polling from location header 253 | response = TestArmPolling.mock_send( 254 | 'PATCH', 202, 255 | {'location': LOCATION_URL}, 256 | body={'properties':{'provisioningState': 'Succeeded'}}) 257 | polling_method = AsyncARMPolling(0) 258 | poll = await async_poller(CLIENT, response, 259 | TestArmPolling.mock_outputs, 260 | polling_method) 261 | assert poll.name == TEST_NAME 262 | assert polling_method._response.randomFieldFromPollLocationHeader is None 263 | 264 | # Test polling from azure-asyncoperation header 265 | response = TestArmPolling.mock_send( 266 | 'PATCH', 202, 267 | {'azure-asyncoperation': ASYNC_URL}, 268 | body={'properties':{'provisioningState': 'Succeeded'}}) 269 | polling_method = AsyncARMPolling(0) 270 | poll = await async_poller(CLIENT, response, 271 | TestArmPolling.mock_outputs, 272 | polling_method) 273 | assert poll.name == TEST_NAME 274 | assert not hasattr(polling_method._response, 'randomFieldFromPollAsyncOpHeader') 275 | 276 | # Test polling from location header 277 | response = TestArmPolling.mock_send( 278 | 'PATCH', 200, 279 | {'location': LOCATION_URL}, 280 | body={'properties':{'provisioningState': 'Succeeded'}}) 281 | polling_method = AsyncARMPolling(0) 282 | poll = await async_poller(CLIENT, response, 283 | TestArmPolling.mock_outputs, 284 | polling_method) 285 | assert poll.name == TEST_NAME 286 | assert polling_method._response.randomFieldFromPollLocationHeader is None 287 | 288 | # Test polling from azure-asyncoperation header 289 | response = TestArmPolling.mock_send( 290 | 'PATCH', 200, 291 | {'azure-asyncoperation': ASYNC_URL}, 292 | body={'properties':{'provisioningState': 'Succeeded'}}) 293 | polling_method = AsyncARMPolling(0) 294 | poll = await async_poller(CLIENT, response, 295 | TestArmPolling.mock_outputs, 296 | polling_method) 297 | assert poll.name == TEST_NAME 298 | assert not hasattr(polling_method._response, 'randomFieldFromPollAsyncOpHeader') 299 | 300 | # Test fail to poll from azure-asyncoperation header 301 | response = TestArmPolling.mock_send( 302 | 'PATCH', 202, 303 | {'azure-asyncoperation': ERROR}) 304 | with pytest.raises(BadEndpointError): 305 | poll = await async_poller(CLIENT, response, 306 | TestArmPolling.mock_outputs, 307 | AsyncARMPolling(0)) 308 | 309 | # Test fail to poll from location header 310 | response = TestArmPolling.mock_send( 311 | 'PATCH', 202, 312 | {'location': ERROR}) 313 | with pytest.raises(BadEndpointError): 314 | poll = await async_poller(CLIENT, response, 315 | TestArmPolling.mock_outputs, 316 | AsyncARMPolling(0)) 317 | 318 | @pytest.mark.asyncio 319 | async def test_long_running_delete(): 320 | # Test polling from azure-asyncoperation header 321 | response = TestArmPolling.mock_send( 322 | 'DELETE', 202, 323 | {'azure-asyncoperation': ASYNC_URL}, 324 | body="" 325 | ) 326 | polling_method = AsyncARMPolling(0) 327 | poll = await async_poller(CLIENT, response, 328 | TestArmPolling.mock_outputs, 329 | polling_method) 330 | assert poll is None 331 | assert polling_method._response.randomFieldFromPollAsyncOpHeader is None 332 | 333 | @pytest.mark.asyncio 334 | async def test_long_running_post(): 335 | 336 | # Test throw on non LRO related status code 337 | response = TestArmPolling.mock_send('POST', 201, {}) 338 | op = LongRunningOperation(response, lambda x:None) 339 | with pytest.raises(BadStatus): 340 | op.set_initial_status(response) 341 | with pytest.raises(CloudError): 342 | await async_poller(CLIENT, response, 343 | TestArmPolling.mock_outputs, 344 | AsyncARMPolling(0)) 345 | 346 | # Test polling from azure-asyncoperation header 347 | response = TestArmPolling.mock_send( 348 | 'POST', 202, 349 | {'azure-asyncoperation': ASYNC_URL}, 350 | body={'properties':{'provisioningState': 'Succeeded'}}) 351 | polling_method = AsyncARMPolling(0) 352 | poll = await async_poller(CLIENT, response, 353 | TestArmPolling.mock_outputs, 354 | polling_method) 355 | #self.assertIsNone(poll) 356 | assert polling_method._response.randomFieldFromPollAsyncOpHeader is None 357 | 358 | # Test polling from location header 359 | response = TestArmPolling.mock_send( 360 | 'POST', 202, 361 | {'location': LOCATION_URL}, 362 | body={'properties':{'provisioningState': 'Succeeded'}}) 363 | polling_method = AsyncARMPolling(0) 364 | poll = await async_poller(CLIENT, response, 365 | TestArmPolling.mock_outputs, 366 | polling_method) 367 | assert poll.name == TEST_NAME 368 | assert polling_method._response.randomFieldFromPollLocationHeader is None 369 | 370 | # Test fail to poll from azure-asyncoperation header 371 | response = TestArmPolling.mock_send( 372 | 'POST', 202, 373 | {'azure-asyncoperation': ERROR}) 374 | with pytest.raises(BadEndpointError): 375 | await async_poller(CLIENT, response, 376 | TestArmPolling.mock_outputs, 377 | AsyncARMPolling(0)) 378 | 379 | # Test fail to poll from location header 380 | response = TestArmPolling.mock_send( 381 | 'POST', 202, 382 | {'location': ERROR}) 383 | with pytest.raises(BadEndpointError): 384 | await async_poller(CLIENT, response, 385 | TestArmPolling.mock_outputs, 386 | AsyncARMPolling(0)) 387 | 388 | @pytest.mark.asyncio 389 | async def test_long_running_negative(): 390 | global LOCATION_BODY 391 | global POLLING_STATUS 392 | 393 | # Test LRO PUT throws for invalid json 394 | LOCATION_BODY = '{' 395 | response = TestArmPolling.mock_send( 396 | 'POST', 202, 397 | {'location': LOCATION_URL}) 398 | poll = async_poller( 399 | CLIENT, 400 | response, 401 | TestArmPolling.mock_outputs, 402 | AsyncARMPolling(0) 403 | ) 404 | with pytest.raises(DeserializationError): 405 | await poll 406 | 407 | LOCATION_BODY = '{\'"}' 408 | response = TestArmPolling.mock_send( 409 | 'POST', 202, 410 | {'location': LOCATION_URL}) 411 | poll = async_poller(CLIENT, response, 412 | TestArmPolling.mock_outputs, 413 | AsyncARMPolling(0)) 414 | with pytest.raises(DeserializationError): 415 | await poll 416 | 417 | LOCATION_BODY = '{' 418 | POLLING_STATUS = 203 419 | response = TestArmPolling.mock_send( 420 | 'POST', 202, 421 | {'location': LOCATION_URL}) 422 | poll = async_poller(CLIENT, response, 423 | TestArmPolling.mock_outputs, 424 | AsyncARMPolling(0)) 425 | with pytest.raises(CloudError): # TODO: Node.js raises on deserialization 426 | await poll 427 | 428 | LOCATION_BODY = json.dumps({ 'name': TEST_NAME }) 429 | POLLING_STATUS = 200 430 | 431 | -------------------------------------------------------------------------------- /tests/test_operation.py: -------------------------------------------------------------------------------- 1 | #-------------------------------------------------------------------------- 2 | # 3 | # Copyright (c) Microsoft Corporation. All rights reserved. 4 | # 5 | # The MIT License (MIT) 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the ""Software""), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | # THE SOFTWARE. 24 | # 25 | #-------------------------------------------------------------------------- 26 | 27 | import json 28 | import re 29 | import unittest 30 | try: 31 | from unittest import mock 32 | except ImportError: 33 | import mock 34 | 35 | from requests import Request, Response 36 | 37 | from msrest import Deserializer 38 | from msrest.exceptions import DeserializationError 39 | from msrestazure.azure_exceptions import CloudError 40 | from msrestazure.azure_operation import ( 41 | LongRunningOperation, 42 | AzureOperationPoller, 43 | BadStatus, 44 | SimpleResource) 45 | 46 | class BadEndpointError(Exception): 47 | pass 48 | 49 | TEST_NAME = 'foo' 50 | RESPONSE_BODY = {'properties':{'provisioningState': 'InProgress'}} 51 | ASYNC_BODY = json.dumps({ 'status': 'Succeeded' }) 52 | ASYNC_URL = 'http://dummyurlFromAzureAsyncOPHeader_Return200' 53 | LOCATION_BODY = json.dumps({ 'name': TEST_NAME }) 54 | LOCATION_URL = 'http://dummyurlurlFromLocationHeader_Return200' 55 | RESOURCE_BODY = json.dumps({ 'name': TEST_NAME }) 56 | RESOURCE_URL = 'http://subscriptions/sub1/resourcegroups/g1/resourcetype1/resource1' 57 | ERROR = 'http://dummyurl_ReturnError' 58 | POLLING_STATUS = 200 59 | 60 | class TestLongRunningOperation(unittest.TestCase): 61 | 62 | convert = re.compile('([a-z0-9])([A-Z])') 63 | 64 | @staticmethod 65 | def mock_send(method, status, headers, body=None): 66 | response = mock.create_autospec(Response) 67 | response.request = mock.create_autospec(Request) 68 | response.request.method = method 69 | response.request.url = RESOURCE_URL 70 | response.status_code = status 71 | response.headers = headers 72 | response.headers.update({"content-type": "application/json; charset=utf8"}) 73 | content = body if body else RESPONSE_BODY 74 | response.text = json.dumps(content) 75 | response.json = lambda: json.loads(response.text) 76 | return lambda: response 77 | 78 | @staticmethod 79 | def mock_update(url, headers=None): 80 | response = mock.create_autospec(Response) 81 | response.request = mock.create_autospec(Request) 82 | response.request.method = 'GET' 83 | response.headers = headers or {} 84 | response.headers.update({"content-type": "application/json; charset=utf8"}) 85 | 86 | if url == ASYNC_URL: 87 | response.request.url = url 88 | response.status_code = POLLING_STATUS 89 | response.text = ASYNC_BODY 90 | response.randomFieldFromPollAsyncOpHeader = None 91 | 92 | elif url == LOCATION_URL: 93 | response.request.url = url 94 | response.status_code = POLLING_STATUS 95 | response.text = LOCATION_BODY 96 | response.randomFieldFromPollLocationHeader = None 97 | 98 | elif url == ERROR: 99 | raise BadEndpointError("boom") 100 | 101 | elif url == RESOURCE_URL: 102 | response.request.url = url 103 | response.status_code = POLLING_STATUS 104 | response.text = RESOURCE_BODY 105 | 106 | else: 107 | raise Exception('URL does not match') 108 | response.json = lambda: json.loads(response.text) 109 | return response 110 | 111 | @staticmethod 112 | def mock_outputs(response): 113 | body = response.json() 114 | body = {TestLongRunningOperation.convert.sub(r'\1_\2', k).lower(): v 115 | for k, v in body.items()} 116 | properties = body.setdefault('properties', {}) 117 | if 'name' in body: 118 | properties['name'] = body['name'] 119 | if properties: 120 | properties = {TestLongRunningOperation.convert.sub(r'\1_\2', k).lower(): v 121 | for k, v in properties.items()} 122 | del body['properties'] 123 | body.update(properties) 124 | resource = SimpleResource(**body) 125 | else: 126 | raise DeserializationError("Impossible to deserialize") 127 | resource = SimpleResource(**body) 128 | return resource 129 | 130 | def test_long_running_put(self): 131 | #TODO: Test custom header field 132 | 133 | # Test throw on non LRO related status code 134 | response = TestLongRunningOperation.mock_send('PUT', 1000, {}) 135 | op = LongRunningOperation(response(), lambda x:None) 136 | with self.assertRaises(BadStatus): 137 | op.set_initial_status(response()) 138 | with self.assertRaises(CloudError): 139 | AzureOperationPoller(response, 140 | TestLongRunningOperation.mock_outputs, 141 | TestLongRunningOperation.mock_update, 0).result() 142 | 143 | # Test with no polling necessary 144 | response_body = { 145 | 'properties':{'provisioningState': 'Succeeded'}, 146 | 'name': TEST_NAME 147 | } 148 | response = TestLongRunningOperation.mock_send( 149 | 'PUT', 201, 150 | {}, response_body 151 | ) 152 | def no_update_allowed(url, headers=None): 153 | raise ValueError("Should not try to update") 154 | poll = AzureOperationPoller(response, 155 | TestLongRunningOperation.mock_outputs, 156 | no_update_allowed, 157 | 0 158 | ) 159 | self.assertEqual(poll.result().name, TEST_NAME) 160 | self.assertFalse(hasattr(poll._response, 'randomFieldFromPollAsyncOpHeader')) 161 | 162 | # Test polling from azure-asyncoperation header 163 | response = TestLongRunningOperation.mock_send( 164 | 'PUT', 201, 165 | {'azure-asyncoperation': ASYNC_URL}) 166 | poll = AzureOperationPoller(response, 167 | TestLongRunningOperation.mock_outputs, 168 | TestLongRunningOperation.mock_update, 0) 169 | self.assertEqual(poll.result().name, TEST_NAME) 170 | self.assertFalse(hasattr(poll._response, 'randomFieldFromPollAsyncOpHeader')) 171 | 172 | # Test polling location header 173 | response = TestLongRunningOperation.mock_send( 174 | 'PUT', 201, 175 | {'location': LOCATION_URL}) 176 | poll = AzureOperationPoller(response, 177 | TestLongRunningOperation.mock_outputs, 178 | TestLongRunningOperation.mock_update, 0) 179 | self.assertEqual(poll.result().name, TEST_NAME) 180 | self.assertIsNone(poll._response.randomFieldFromPollLocationHeader) 181 | 182 | # Test polling initial payload invalid (SQLDb) 183 | response_body = {} # Empty will raise 184 | response = TestLongRunningOperation.mock_send( 185 | 'PUT', 201, 186 | {'location': LOCATION_URL}, response_body) 187 | poll = AzureOperationPoller(response, 188 | TestLongRunningOperation.mock_outputs, 189 | TestLongRunningOperation.mock_update, 0) 190 | self.assertEqual(poll.result().name, TEST_NAME) 191 | self.assertIsNone(poll._response.randomFieldFromPollLocationHeader) 192 | 193 | # Test fail to poll from azure-asyncoperation header 194 | response = TestLongRunningOperation.mock_send( 195 | 'PUT', 201, 196 | {'azure-asyncoperation': ERROR}) 197 | with self.assertRaises(BadEndpointError): 198 | poll = AzureOperationPoller(response, 199 | TestLongRunningOperation.mock_outputs, 200 | TestLongRunningOperation.mock_update, 0).result() 201 | 202 | # Test fail to poll from location header 203 | response = TestLongRunningOperation.mock_send( 204 | 'PUT', 201, 205 | {'location': ERROR}) 206 | with self.assertRaises(BadEndpointError): 207 | poll = AzureOperationPoller(response, 208 | TestLongRunningOperation.mock_outputs, 209 | TestLongRunningOperation.mock_update, 0).result() 210 | 211 | def test_long_running_patch(self): 212 | 213 | # Test polling from location header 214 | response = TestLongRunningOperation.mock_send( 215 | 'PATCH', 202, 216 | {'location': LOCATION_URL}, 217 | body={'properties':{'provisioningState': 'Succeeded'}}) 218 | poll = AzureOperationPoller(response, 219 | TestLongRunningOperation.mock_outputs, 220 | TestLongRunningOperation.mock_update, 0) 221 | self.assertEqual(poll.result().name, TEST_NAME) 222 | self.assertIsNone(poll._response.randomFieldFromPollLocationHeader) 223 | 224 | # Test polling from azure-asyncoperation header 225 | response = TestLongRunningOperation.mock_send( 226 | 'PATCH', 202, 227 | {'azure-asyncoperation': ASYNC_URL}, 228 | body={'properties':{'provisioningState': 'Succeeded'}}) 229 | poll = AzureOperationPoller(response, 230 | TestLongRunningOperation.mock_outputs, 231 | TestLongRunningOperation.mock_update, 0) 232 | self.assertEqual(poll.result().name, TEST_NAME) 233 | self.assertFalse(hasattr(poll._response, 'randomFieldFromPollAsyncOpHeader')) 234 | 235 | # Test polling from location header 236 | response = TestLongRunningOperation.mock_send( 237 | 'PATCH', 200, 238 | {'location': LOCATION_URL}, 239 | body={'properties':{'provisioningState': 'Succeeded'}}) 240 | poll = AzureOperationPoller(response, 241 | TestLongRunningOperation.mock_outputs, 242 | TestLongRunningOperation.mock_update, 0) 243 | self.assertEqual(poll.result().name, TEST_NAME) 244 | self.assertIsNone(poll._response.randomFieldFromPollLocationHeader) 245 | 246 | # Test polling from azure-asyncoperation header 247 | response = TestLongRunningOperation.mock_send( 248 | 'PATCH', 200, 249 | {'azure-asyncoperation': ASYNC_URL}, 250 | body={'properties':{'provisioningState': 'Succeeded'}}) 251 | poll = AzureOperationPoller(response, 252 | TestLongRunningOperation.mock_outputs, 253 | TestLongRunningOperation.mock_update, 0) 254 | self.assertEqual(poll.result().name, TEST_NAME) 255 | self.assertFalse(hasattr(poll._response, 'randomFieldFromPollAsyncOpHeader')) 256 | 257 | # Test fail to poll from azure-asyncoperation header 258 | response = TestLongRunningOperation.mock_send( 259 | 'PATCH', 202, 260 | {'azure-asyncoperation': ERROR}) 261 | with self.assertRaises(BadEndpointError): 262 | poll = AzureOperationPoller(response, 263 | TestLongRunningOperation.mock_outputs, 264 | TestLongRunningOperation.mock_update, 0).result() 265 | 266 | # Test fail to poll from location header 267 | response = TestLongRunningOperation.mock_send( 268 | 'PATCH', 202, 269 | {'location': ERROR}) 270 | with self.assertRaises(BadEndpointError): 271 | poll = AzureOperationPoller(response, 272 | TestLongRunningOperation.mock_outputs, 273 | TestLongRunningOperation.mock_update, 0).result() 274 | 275 | def test_long_running_delete(self): 276 | # Test polling from azure-asyncoperation header 277 | response = TestLongRunningOperation.mock_send( 278 | 'DELETE', 202, 279 | {'azure-asyncoperation': ASYNC_URL}) 280 | poll = AzureOperationPoller(response, 281 | TestLongRunningOperation.mock_outputs, 282 | TestLongRunningOperation.mock_update, 0) 283 | poll.wait() 284 | self.assertIsNone(poll.result()) 285 | self.assertIsNone(poll._response.randomFieldFromPollAsyncOpHeader) 286 | 287 | def test_long_running_post(self): 288 | 289 | # Test throw on non LRO related status code 290 | response = TestLongRunningOperation.mock_send('POST', 201, {}) 291 | op = LongRunningOperation(response(), lambda x:None) 292 | with self.assertRaises(BadStatus): 293 | op.set_initial_status(response()) 294 | with self.assertRaises(CloudError): 295 | AzureOperationPoller(response, 296 | TestLongRunningOperation.mock_outputs, 297 | TestLongRunningOperation.mock_update, 0).result() 298 | 299 | # Test polling from azure-asyncoperation header 300 | response = TestLongRunningOperation.mock_send( 301 | 'POST', 202, 302 | {'azure-asyncoperation': ASYNC_URL}, 303 | body={'properties':{'provisioningState': 'Succeeded'}}) 304 | poll = AzureOperationPoller(response, 305 | TestLongRunningOperation.mock_outputs, 306 | TestLongRunningOperation.mock_update, 0) 307 | poll.wait() 308 | #self.assertIsNone(poll.result()) 309 | self.assertIsNone(poll._response.randomFieldFromPollAsyncOpHeader) 310 | 311 | # Test polling from location header 312 | response = TestLongRunningOperation.mock_send( 313 | 'POST', 202, 314 | {'location': LOCATION_URL}, 315 | body={'properties':{'provisioningState': 'Succeeded'}}) 316 | poll = AzureOperationPoller(response, 317 | TestLongRunningOperation.mock_outputs, 318 | TestLongRunningOperation.mock_update, 0) 319 | self.assertEqual(poll.result().name, TEST_NAME) 320 | self.assertIsNone(poll._response.randomFieldFromPollLocationHeader) 321 | 322 | # Test fail to poll from azure-asyncoperation header 323 | response = TestLongRunningOperation.mock_send( 324 | 'POST', 202, 325 | {'azure-asyncoperation': ERROR}) 326 | with self.assertRaises(BadEndpointError): 327 | poll = AzureOperationPoller(response, 328 | TestLongRunningOperation.mock_outputs, 329 | TestLongRunningOperation.mock_update, 0).result() 330 | 331 | # Test fail to poll from location header 332 | response = TestLongRunningOperation.mock_send( 333 | 'POST', 202, 334 | {'location': ERROR}) 335 | with self.assertRaises(BadEndpointError): 336 | poll = AzureOperationPoller(response, 337 | TestLongRunningOperation.mock_outputs, 338 | TestLongRunningOperation.mock_update, 0).result() 339 | 340 | def test_long_running_negative(self): 341 | global LOCATION_BODY 342 | global POLLING_STATUS 343 | 344 | # Test LRO PUT throws for invalid json 345 | LOCATION_BODY = '{' 346 | response = TestLongRunningOperation.mock_send( 347 | 'POST', 202, 348 | {'location': LOCATION_URL}) 349 | poll = AzureOperationPoller(response, 350 | TestLongRunningOperation.mock_outputs, 351 | TestLongRunningOperation.mock_update, 0) 352 | with self.assertRaises(DeserializationError): 353 | poll.wait() 354 | 355 | LOCATION_BODY = '{\'"}' 356 | response = TestLongRunningOperation.mock_send( 357 | 'POST', 202, 358 | {'location': LOCATION_URL}) 359 | poll = AzureOperationPoller(response, 360 | TestLongRunningOperation.mock_outputs, 361 | TestLongRunningOperation.mock_update, 0) 362 | with self.assertRaises(DeserializationError): 363 | poll.wait() 364 | 365 | LOCATION_BODY = '{' 366 | POLLING_STATUS = 203 367 | response = TestLongRunningOperation.mock_send( 368 | 'POST', 202, 369 | {'location': LOCATION_URL}) 370 | poll = AzureOperationPoller(response, 371 | TestLongRunningOperation.mock_outputs, 372 | TestLongRunningOperation.mock_update, 0) 373 | with self.assertRaises(CloudError): # TODO: Node.js raises on deserialization 374 | poll.wait() 375 | 376 | LOCATION_BODY = json.dumps({ 'name': TEST_NAME }) 377 | POLLING_STATUS = 200 378 | 379 | 380 | 381 | if __name__ == '__main__': 382 | unittest.main() 383 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | AutoRest: Python Client Runtime - Azure Module 2 | =============================================== 3 | 4 | .. image:: https://travis-ci.org/Azure/msrestazure-for-python.svg?branch=master 5 | :target: https://travis-ci.org/Azure/msrestazure-for-python 6 | 7 | .. image:: https://codecov.io/gh/azure/msrestazure-for-python/branch/master/graph/badge.svg 8 | :target: https://codecov.io/gh/azure/msrestazure-for-python 9 | 10 | Disclaimer 11 | ---------- 12 | 13 | *This package is deprecated and no longer receives updates* 14 | 15 | - The authentication part of this package has been moved to `azure-identity `_ 16 | - The other parts of this library are covered by `azure-mgmt-core `_ 17 | 18 | As such, we will no longer accept PR and fix issues on this project. 19 | 20 | Installation 21 | ------------ 22 | 23 | To install: 24 | 25 | .. code-block:: bash 26 | 27 | $ pip install msrestazure 28 | 29 | 30 | Release History 31 | --------------- 32 | 33 | 2020-06-29 Version 0.6.4 34 | ++++++++++++++++++++++++ 35 | 36 | **Bugfix** 37 | 38 | - Unable to raise exception if JSON body contains UTF-8 characters on Python 2 #150 39 | 40 | 41 | 2020-03-17 Version 0.6.3 42 | ++++++++++++++++++++++++ 43 | 44 | **Bugfix** 45 | 46 | - Unable to raise exception if JSON body contains UTF-8 characters #144 47 | - Prepare old poller implementation to Python 3.9 #138 48 | 49 | **Features** 50 | 51 | - Add Microsoft Graph to Cloud environment #142 52 | 53 | Thanks to @psignoret and @tirkarthi for his contribution 54 | 55 | 2019-09-16 Version 0.6.2 56 | ++++++++++++++++++++++++ 57 | 58 | **Bugfix** 59 | 60 | - Fix ARM error parsing if Type info is used #135 61 | 62 | 2019-06-10 Version 0.6.1 63 | ++++++++++++++++++++++++ 64 | 65 | **Features** 66 | 67 | - Add User Assigned identity support for WebApp/Functions #124 68 | - Add timeout parameter for MSI token, is used from a VM #131 69 | 70 | Thanks to @noelbundick for his contribution 71 | 72 | 2018-12-17 Version 0.6.0 73 | ++++++++++++++++++++++++ 74 | 75 | **Features** 76 | 77 | - Implementation of LRO async, based on msrest 0.6.x series (*experimental*) 78 | 79 | **Disclaimer** 80 | 81 | - This version contains no direct breaking changes, but is bumped to 0.6.x since it requires a breaking change version of msrest. 82 | 83 | Thanks to @gison93 for his documentation contribution 84 | 85 | 2018-11-01 Version 0.5.1 86 | ++++++++++++++++++++++++ 87 | 88 | **Bugfixes** 89 | 90 | - Fix CloudError if response and error message are provided at the same time #114 91 | - Fix LRO polling if last call is an empty Location (Autorest.Python 3.x only) #120 92 | 93 | **Features** 94 | 95 | - Altered resource id parsing logic to allow for resource group IDs #117 96 | 97 | 2018-08-02 Version 0.5.0 98 | ++++++++++++++++++++++++ 99 | 100 | **Features** 101 | 102 | - Implementation is now using ADAL and not request-oauthlib. This allows more AD scenarios (like federated) #94 103 | - Add additionalInfo parsing for CloudError #102 104 | 105 | **Breaking changes** 106 | 107 | These breaking changes applies to ServicePrincipalCredentials, UserPassCredentials, AADTokenCredentials 108 | 109 | - Remove "auth_uri" attribute and parameter. This was unused. 110 | - Remove "state" attribute. This was unused. 111 | - Remove "client" attribute. This was exposed by mistake and should have been internal. No replacement is possible. 112 | - Remove "token_uri" attribute and parameter. Use "cloud_environment" and "tenant" to impact the login url now. 113 | - Remove token caching based on "keyring". Token caching should be implemented using ADAL now. This implies: 114 | 115 | - Remove the "keyring" parameter 116 | - Remove the "clear_cached_token" method 117 | - Remove the "retrieve_session" method 118 | 119 | 2018-07-03 Version 0.4.35 120 | +++++++++++++++++++++++++ 121 | 122 | **Bugfixes** 123 | 124 | - MSIAuthentication regression for KeyVault since IMDS support #109 125 | 126 | 2018-07-02 Version 0.4.34 127 | +++++++++++++++++++++++++ 128 | 129 | **Bugfixes** 130 | 131 | - MSIAuthentication should initialize the token attribute on creation #106 132 | 133 | 2018-06-21 Version 0.4.33 134 | +++++++++++++++++++++++++ 135 | 136 | **Bugfixes** 137 | 138 | - Fixes refreshToken in UserPassCredentials and AADTokenCredentials #103 139 | - Fix US government cloud definition #104 140 | 141 | Thanks to mjcaley for his contribution 142 | 143 | 2018-06-13 Version 0.4.32 144 | +++++++++++++++++++++++++ 145 | 146 | **Features** 147 | 148 | - Implement new LRO options of Autorest #101 149 | 150 | **Bug fixes** 151 | 152 | - Reduce max MSI polling time for VM #100 153 | 154 | 155 | 2018-05-17 Version 0.4.31 156 | +++++++++++++++++++++++++ 157 | 158 | **Features** 159 | 160 | - Improve MSI for VM token polling algorithm 161 | 162 | 2018-05-16 Version 0.4.30 163 | +++++++++++++++++++++++++ 164 | 165 | **Features** 166 | 167 | - Allow ADAL 0.5.0 to 2.0.0 excluded as valid ADAL dependency 168 | 169 | 2018-04-30 Version 0.4.29 170 | +++++++++++++++++++++++++ 171 | 172 | **Bugfixes** 173 | 174 | - Fix refresh Token on `AADTokenCredentials` (was broken in 0.4.27) 175 | - Now `UserPasswordCredentials` correctly use the refreshToken, and not user/password to refresh the session (was broken in 0.4.27) 176 | - Bring back `keyring`, with minimal dependency 12.0.2 that fixes the installation problem on old Python 177 | 178 | 2018-04-23 Version 0.4.28 179 | +++++++++++++++++++++++++ 180 | 181 | **Disclaimer** 182 | 183 | Do to some stability issues with "keyring" dependency that highly change from one system to another, 184 | this package is no longer a dependency of "msrestazure". 185 | If you were using the secured token cache of `ServicePrincipalCredentials` and `UserPassCredentials`, 186 | the feature is still available, but you need to install manually "keyring". The functionnality will activate automatically. 187 | 188 | 2018-04-18 Version 0.4.27 189 | +++++++++++++++++++++++++ 190 | 191 | **Features** 192 | 193 | - Implements new features of msrest 0.4.28 on session improvement. See msrest ChangeLog for details. 194 | 195 | Update msrest dependency to 0.4.28 196 | 197 | 2018-04-17 Version 0.4.26 198 | +++++++++++++++++++++++++ 199 | 200 | **Bugfixes** 201 | 202 | - IMDS/MSI: Retry on more error codes (#87) 203 | - IMDS/MSI: fix a boundary case on timeout (#86) 204 | 205 | 2018-03-29 Version 0.4.25 206 | +++++++++++++++++++++++++ 207 | 208 | **Features** 209 | 210 | - MSIAuthentication now uses IMDS endpoint if available 211 | - MSIAuthentication can be used in any environment that defines MSI_ENDPOINT env variable 212 | 213 | 2018-03-26 Version 0.4.24 214 | +++++++++++++++++++++++++ 215 | 216 | **Bugfix** 217 | 218 | - Fix parse_resource_id() tool to be case-insensitive to keywords when matching #81 219 | - Add missing baseclass init call for AdalAuthentication #82 220 | 221 | 2018-03-19 Version 0.4.23 222 | +++++++++++++++++++++++++ 223 | 224 | **Bugfix** 225 | 226 | - Fix LRO result if POST uses AsyncOperation header (Autorest.Python 3.0 only) #79 227 | 228 | 2018-02-27 Version 0.4.22 229 | +++++++++++++++++++++++++ 230 | 231 | **Bugfix** 232 | 233 | - Remove a possible infinite loop with MSIAuthentication #77 234 | 235 | **Disclaimer** 236 | 237 | From this version, MSIAuthentication will fail instantly if you try to get MSI token 238 | from a VM where the extension is not installed, or not yet ready. 239 | You need to do your own retry mechanism if you think the extension is provisioning and 240 | the call might succeed later. 241 | This behavior is consistent with other Azure SDK implementation of MSI scenarios. 242 | 243 | 2018-01-26 Version 0.4.21 244 | +++++++++++++++++++++++++ 245 | 246 | - Update allowed ADAL dependency to 0.5.x 247 | 248 | 2018-01-08 Version 0.4.20 249 | +++++++++++++++++++++++++ 250 | 251 | **Features** 252 | 253 | - CloudError now includes the "innererror" attribute to match OData v4 #73 254 | - Introduces ARMPolling implementation of Azure Resource Management LRO. Requires msrest 0.4.25 (new dependency). 255 | This is used by code generated with Autorest.Python 3.0, and is not used by code generated by previous Autorest version. 256 | - Change msrest dependency to ">=0.4.25,<2.0.0" to allow (future) msrest 1.0.0 as compatible dependency. 257 | 258 | Thank you to demyanenko for his contribution. 259 | 260 | 2017-12-14 Version 0.4.19 261 | +++++++++++++++++++++++++ 262 | 263 | **Feature** 264 | 265 | * Improve MSIAuthentication to support User Assigned Identity #70 266 | 267 | **Bugfixes** 268 | 269 | * Fix session obj for cloudmetadata endpoint #67 270 | * Fix authentication resource node for AzureSatck #65 271 | * Better detection of AppService with MSIAuthentication #70 272 | 273 | 2017-12-01 Version 0.4.18 274 | +++++++++++++++++++++++++ 275 | 276 | **Bugfixes** 277 | 278 | - get_cloud_from_metadata_endpoint incorrect on AzureStack #62 279 | - get_cloud_from_metadata_endpoint certificate issue #61 280 | 281 | 2017-11-22 Version 0.4.17 282 | +++++++++++++++++++++++++ 283 | 284 | **Bugfixes** 285 | 286 | - Fix AttributeError if error JSON from ARM does not follow ODatav4 (as it should) 287 | 288 | 2017-10-31 Version 0.4.16 289 | +++++++++++++++++++++++++ 290 | 291 | **Bugfixes** 292 | 293 | - Fix AttributeError if input JSON is not a dict (#54) 294 | 295 | 2017-10-13 Version 0.4.15 296 | +++++++++++++++++++++++++ 297 | 298 | **Features** 299 | 300 | - Add support for WebApp/Functions in MSIAuthentication classes 301 | - Add parse_resource_id(), resource_id(), validate_resource_id() to parse ARM ids 302 | - Retry strategy now n reach 24 seconds (instead of 12 seconds) 303 | 304 | 2017-09-11 Version 0.4.14 305 | +++++++++++++++++++++++++ 306 | 307 | **Features** 308 | 309 | - Add Managed Service Integrated (MSI) authentication 310 | 311 | **Bug fix** 312 | 313 | - Fix AdalError handling in some scenarios (#44) 314 | 315 | Thank you to Hexadite-Omer for his contribution 316 | 317 | 2017-08-24 Version 0.4.13 318 | +++++++++++++++++++++++++ 319 | 320 | **Features** 321 | 322 | - "keyring" is now completely optional 323 | 324 | 2017-08-23 Version 0.4.12 325 | +++++++++++++++++++++++++ 326 | 327 | **Features** 328 | 329 | - add "timeout" to ServicePrincipalCredentials and UserPasswordCredentials 330 | - Threads created by AzureOperationPoller have now a name prefixed by "AzureOperationPoller" to help identify them 331 | 332 | **Bugfixes** 333 | 334 | - Do not fail if keyring is badly installed 335 | - Update Azure Gov login endpoint 336 | - Update metadata ARM endpoint parser 337 | 338 | **Breaking changes** 339 | 340 | - Remove InteractiveCredentials. This class was deprecated and unusable. Use ADAL device code instead. 341 | 342 | 2017-06-29 Version 0.4.11 343 | +++++++++++++++++++++++++ 344 | 345 | **Features** 346 | 347 | - Add cloud definitions for public Azure, German Azure, China Azure and Azure Gov 348 | - Add get_cloud_from_metadata_endpoint to automatically create a Cloud object from an ARM endpoint 349 | - Add `cloud_environment` to all Credentials objects (except AdalAuthentication) 350 | 351 | **Note** 352 | 353 | - This deprecates "china=True", to be replaced by "cloud_environment=AZURE_CHINA_CLOUD" 354 | 355 | Example: 356 | 357 | .. code:: python 358 | 359 | from msrestazure.azure_cloud import AZURE_CHINA_CLOUD 360 | from msrestazure.azure_active_directory import UserPassCredentials 361 | 362 | credentials = UserPassCredentials( 363 | login, 364 | password, 365 | cloud_environment=AZURE_CHINA_CLOUD 366 | ) 367 | 368 | `base_url` of SDK client can be pointed to "cloud_environment.endpoints.resource_manager" for basic scenario: 369 | 370 | Example: 371 | 372 | .. code:: python 373 | 374 | from msrestazure.azure_cloud import AZURE_CHINA_CLOUD 375 | from msrestazure.azure_active_directory import UserPassCredentials 376 | from azure.mgmt.resource import ResourceManagementClient 377 | 378 | credentials = UserPassCredentials( 379 | login, 380 | password, 381 | cloud_environment=AZURE_CHINA_CLOUD 382 | ) 383 | client = ResourceManagementClient( 384 | credentials, 385 | subscription_id, 386 | base_url=AZURE_CHINA_CLOUD.endpoints.resource_manager 387 | ) 388 | 389 | Azure Stack connection can be done: 390 | 391 | .. code:: python 392 | 393 | from msrestazure.azure_cloud import get_cloud_from_metadata_endpoint 394 | from msrestazure.azure_active_directory import UserPassCredentials 395 | from azure.mgmt.resource import ResourceManagementClient 396 | 397 | mystack_cloud = get_cloud_from_metadata_endpoint("https://myazurestack-arm-endpoint.com") 398 | credentials = UserPassCredentials( 399 | login, 400 | password, 401 | cloud_environment=mystack_cloud 402 | ) 403 | client = ResourceManagementClient( 404 | credentials, 405 | subscription_id, 406 | base_url=mystack_cloud.endpoints.resource_manager 407 | ) 408 | 409 | 410 | 2017-06-27 Version 0.4.10 411 | +++++++++++++++++++++++++ 412 | 413 | **Bugfixes** 414 | 415 | - Accept PATCH/201 as LRO valid state 416 | - Close token session on exit (ServicePrincipal and UserPassword credentials) 417 | 418 | 2017-06-19 Version 0.4.9 419 | ++++++++++++++++++++++++ 420 | 421 | **Features** 422 | 423 | - Add proxies parameters to ServicePrincipal and UserPassword credentials class #29 424 | - Add automatic Azure provider registration if needed (requires msrest 0.4.10) #28 425 | 426 | Thank you to likel for his contribution 427 | 428 | 2017-05-31 Version 0.4.8 429 | ++++++++++++++++++++++++ 430 | 431 | **Bugfixes** 432 | 433 | - Fix LRO if first call never returns 200, but ends on 201 (#26) 434 | - FiX LRO AttributeError if timeout is short (#21) 435 | 436 | **Features** 437 | 438 | - Expose a "status()" method in AzureOperationPoller (#18) 439 | 440 | 2017-01-23 Version 0.4.7 441 | ++++++++++++++++++++++++ 442 | 443 | **Bugfixes** 444 | 445 | - Adding `accept_language` and `generate_client_request_id` default values 446 | 447 | 2016-12-12 Version 0.4.6 448 | ++++++++++++++++++++++++ 449 | 450 | **Bugfixes** 451 | 452 | Refactor Long Running Operation algorithm. 453 | 454 | - There is no breaking changes, however you might need to record again your offline HTTP records 455 | if you use unittests with VCRpy. 456 | - Fix a couple of latent bugs 457 | 458 | 2016-11-30 Version 0.4.5 459 | ++++++++++++++++++++++++ 460 | 461 | **New features** 462 | 463 | - Add AdalAuthentification class to wrap ADAL library (https://github.com/Azure/msrestazure-for-python/pull/8) 464 | 465 | 2016-10-17 Version 0.4.4 466 | ++++++++++++++++++++++++ 467 | 468 | **Bugfixes** 469 | 470 | - More informative and well-formed CloudError exceptions (https://github.com/Azure/autorest/issues/1460) 471 | - Raise CustomException is defined in Swagger (https://github.com/Azure/autorest/issues/1404) 472 | 473 | 2016-09-14 Version 0.4.3 474 | ++++++++++++++++++++++++ 475 | 476 | **Bugfixes** 477 | 478 | - Make AzureOperationPoller thread as daemon (do not block anymore a Ctrl+C) (https://github.com/Azure/autorest/pull/1379) 479 | 480 | 2016-09-01 Version 0.4.2 481 | ++++++++++++++++++++++++ 482 | 483 | **Bugfixes** 484 | 485 | - Better exception message (https://github.com/Azure/autorest/pull/1300) 486 | 487 | This version needs msrest >= 0.4.3 488 | 489 | 2016-06-08 Version 0.4.1 490 | ++++++++++++++++++++++++ 491 | 492 | **Bugfixes** 493 | 494 | - Fix for LRO PUT operation https://github.com/Azure/autorest/issues/1133 495 | 496 | 2016-05-25 Version 0.4.0 497 | ++++++++++++++++++++++++ 498 | 499 | Update msrest dependency to 0.4.0 500 | 501 | **Bugfixes** 502 | 503 | - Fix for several AAD issues https://github.com/Azure/autorest/issues/1055 504 | - Fix for LRO PATCH bug and refactor https://github.com/Azure/autorest/issues/993 505 | 506 | **Behaviour changes** 507 | 508 | - Needs Autorest > 0.17.0 Nightly 20160525 509 | 510 | 511 | 2016-04-26 Version 0.3.0 512 | ++++++++++++++++++++++++ 513 | 514 | Update msrest dependency to 0.3.0 515 | 516 | **Bugfixes** 517 | 518 | - Read only values are no longer in __init__ or sent to the server (https://github.com/Azure/autorest/pull/959) 519 | - Useless kwarg removed 520 | 521 | **Behaviour changes** 522 | 523 | - Needs Autorest > 0.16.0 Nightly 20160426 524 | 525 | 526 | 2016-03-31 Version 0.2.1 527 | ++++++++++++++++++++++++ 528 | 529 | **Bugfixes** 530 | 531 | - Fix AzurePollerOperation if Swagger defines provisioning status as enum type (https://github.com/Azure/autorest/pull/892) 532 | 533 | 534 | 2016-03-25 Version 0.2.0 535 | ++++++++++++++++++++++++ 536 | 537 | Update msrest dependency to 0.2.0 538 | 539 | **Behaviour change** 540 | 541 | - async methods called with raw=True don't return anymore AzureOperationPoller but ClientRawResponse 542 | - Needs Autorest > 0.16.0 Nightly 20160324 543 | 544 | 545 | 2016-03-21 Version 0.1.2 546 | ++++++++++++++++++++++++ 547 | 548 | Update msrest dependency to 0.1.3 549 | 550 | **Bugfixes** 551 | 552 | - AzureOperationPoller.wait() failed to raise exception if query error (https://github.com/Azure/autorest/pull/856) 553 | 554 | 555 | 2016-03-04 Version 0.1.1 556 | ++++++++++++++++++++++++ 557 | 558 | **Bugfixes** 559 | 560 | - Source package corrupted in Pypi (https://github.com/Azure/autorest/issues/799) 561 | 562 | 2016-03-04 Version 0.1.0 563 | ++++++++++++++++++++++++ 564 | 565 | **Behaviour change** 566 | 567 | - Replaced _required attribute in CloudErrorData class with _validation dict. 568 | 569 | 2016-02-29 Version 0.0.2 570 | ++++++++++++++++++++++++ 571 | 572 | **Bugfixes** 573 | 574 | - Fixed AAD bug to include connection verification in UserPassCredentials. (https://github.com/Azure/autorest/pull/725) 575 | - Source package corrupted in Pypi (https://github.com/Azure/autorest/issues/718) 576 | 577 | 2016-02-19 Version 0.0.1 578 | ++++++++++++++++++++++++ 579 | 580 | - Initial release. 581 | -------------------------------------------------------------------------------- /msrestazure/polling/arm_polling.py: -------------------------------------------------------------------------------- 1 | # -------------------------------------------------------------------------- 2 | # 3 | # Copyright (c) Microsoft Corporation. All rights reserved. 4 | # 5 | # The MIT License (MIT) 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the ""Software""), to 9 | # deal in the Software without restriction, including without limitation the 10 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 11 | # sell copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 22 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 23 | # IN THE SOFTWARE. 24 | # 25 | # -------------------------------------------------------------------------- 26 | import json 27 | import time 28 | try: 29 | from urlparse import urlparse 30 | except ImportError: 31 | from urllib.parse import urlparse 32 | 33 | from msrest.exceptions import DeserializationError 34 | from msrest.polling import PollingMethod 35 | 36 | from ..azure_exceptions import CloudError 37 | 38 | 39 | FINISHED = frozenset(['succeeded', 'canceled', 'failed']) 40 | FAILED = frozenset(['canceled', 'failed']) 41 | SUCCEEDED = frozenset(['succeeded']) 42 | 43 | _AZURE_ASYNC_OPERATION_FINAL_STATE = "azure-async-operation" 44 | _LOCATION_FINAL_STATE = "location" 45 | 46 | def finished(status): 47 | if hasattr(status, 'value'): 48 | status = status.value 49 | return str(status).lower() in FINISHED 50 | 51 | 52 | def failed(status): 53 | if hasattr(status, 'value'): 54 | status = status.value 55 | return str(status).lower() in FAILED 56 | 57 | 58 | def succeeded(status): 59 | if hasattr(status, 'value'): 60 | status = status.value 61 | return str(status).lower() in SUCCEEDED 62 | 63 | 64 | class BadStatus(Exception): 65 | pass 66 | 67 | 68 | class BadResponse(Exception): 69 | pass 70 | 71 | 72 | class OperationFailed(Exception): 73 | pass 74 | 75 | def _validate(url): 76 | """Validate a url. 77 | 78 | :param str url: Polling URL extracted from response header. 79 | :raises: ValueError if URL has no scheme or host. 80 | """ 81 | if url is None: 82 | return 83 | parsed = urlparse(url) 84 | if not parsed.scheme or not parsed.netloc: 85 | raise ValueError("Invalid URL header") 86 | 87 | def get_header_url(response, header_name): 88 | """Get a URL from a header requests. 89 | 90 | :param requests.Response response: REST call response. 91 | :param str header_name: Header name. 92 | :returns: URL if not None AND valid, None otherwise 93 | """ 94 | url = response.headers.get(header_name) 95 | try: 96 | _validate(url) 97 | except ValueError: 98 | return None 99 | else: 100 | return url 101 | 102 | 103 | class LongRunningOperation(object): 104 | """LongRunningOperation 105 | Provides default logic for interpreting operation responses 106 | and status updates. 107 | 108 | :param requests.Response response: The initial response. 109 | :param callable deserialization_callback: The deserialization callaback. 110 | :param dict lro_options: LRO options. 111 | :param kwargs: Unused for now 112 | """ 113 | 114 | def __init__(self, response, deserialization_callback, lro_options=None, **kwargs): 115 | self.method = response.request.method 116 | self.initial_response = response 117 | self.status = "" 118 | self.resource = None 119 | self.deserialization_callback = deserialization_callback 120 | self.async_url = None 121 | self.location_url = None 122 | if lro_options is None: 123 | lro_options = { 124 | 'final-state-via': _AZURE_ASYNC_OPERATION_FINAL_STATE 125 | } 126 | self.lro_options = lro_options 127 | 128 | def _raise_if_bad_http_status_and_method(self, response): 129 | """Check response status code is valid for a Put or Patch 130 | request. Must be 200, 201, 202, or 204. 131 | 132 | :raises: BadStatus if invalid status. 133 | """ 134 | code = response.status_code 135 | if code in {200, 202} or \ 136 | (code == 201 and self.method in {'PUT', 'PATCH'}) or \ 137 | (code == 204 and self.method in {'DELETE', 'POST'}): 138 | return 139 | raise BadStatus( 140 | "Invalid return status for {!r} operation".format(self.method)) 141 | 142 | def _is_empty(self, response): 143 | """Check if response body contains meaningful content. 144 | 145 | :rtype: bool 146 | :raises: DeserializationError if response body contains invalid json data. 147 | """ 148 | # Assume ClientResponse has "body", and otherwise it's a requests.Response 149 | content = response.text() if hasattr(response, "body") else response.text 150 | if not content: 151 | return True 152 | try: 153 | return not json.loads(content) 154 | except ValueError: 155 | raise DeserializationError( 156 | "Error occurred in deserializing the response body.") 157 | 158 | def _as_json(self, response): 159 | """Assuming this is not empty, return the content as JSON. 160 | 161 | Result/exceptions is not determined if you call this method without testing _is_empty. 162 | 163 | :raises: DeserializationError if response body contains invalid json data. 164 | """ 165 | # Assume ClientResponse has "body", and otherwise it's a requests.Response 166 | content = response.text() if hasattr(response, "body") else response.text 167 | try: 168 | return json.loads(content) 169 | except ValueError: 170 | raise DeserializationError( 171 | "Error occurred in deserializing the response body.") 172 | 173 | def _deserialize(self, response): 174 | """Attempt to deserialize resource from response. 175 | 176 | :param requests.Response response: latest REST call response. 177 | """ 178 | return self.deserialization_callback(response) 179 | 180 | def _get_async_status(self, response): 181 | """Attempt to find status info in response body. 182 | 183 | :param requests.Response response: latest REST call response. 184 | :rtype: str 185 | :returns: Status if found, else 'None'. 186 | """ 187 | if self._is_empty(response): 188 | return None 189 | body = self._as_json(response) 190 | return body.get('status') 191 | 192 | def _get_provisioning_state(self, response): 193 | """ 194 | Attempt to get provisioning state from resource. 195 | :param requests.Response response: latest REST call response. 196 | :returns: Status if found, else 'None'. 197 | """ 198 | if self._is_empty(response): 199 | return None 200 | body = self._as_json(response) 201 | return body.get("properties", {}).get("provisioningState") 202 | 203 | def should_do_final_get(self): 204 | """Check whether the polling should end doing a final GET. 205 | 206 | :param requests.Response response: latest REST call response. 207 | :rtype: bool 208 | """ 209 | return ((self.async_url or not self.resource) and self.method in {'PUT', 'PATCH'}) \ 210 | or (self.lro_options['final-state-via'] == _LOCATION_FINAL_STATE and self.location_url and self.async_url and self.method == 'POST') 211 | 212 | def set_initial_status(self, response): 213 | """Process first response after initiating long running 214 | operation and set self.status attribute. 215 | 216 | :param requests.Response response: initial REST call response. 217 | """ 218 | self._raise_if_bad_http_status_and_method(response) 219 | 220 | if self._is_empty(response): 221 | self.resource = None 222 | else: 223 | try: 224 | self.resource = self._deserialize(response) 225 | except DeserializationError: 226 | self.resource = None 227 | 228 | self.set_async_url_if_present(response) 229 | 230 | if response.status_code in {200, 201, 202, 204}: 231 | if self.async_url or self.location_url or response.status_code == 202: 232 | self.status = 'InProgress' 233 | elif response.status_code == 201: 234 | status = self._get_provisioning_state(response) 235 | self.status = status or 'InProgress' 236 | elif response.status_code == 200: 237 | status = self._get_provisioning_state(response) 238 | self.status = status or 'Succeeded' 239 | elif response.status_code == 204: 240 | self.status = 'Succeeded' 241 | self.resource = None 242 | else: 243 | raise OperationFailed("Invalid status found") 244 | return 245 | raise OperationFailed("Operation failed or cancelled") 246 | 247 | def get_status_from_location(self, response): 248 | """Process the latest status update retrieved from a 'location' 249 | header. 250 | 251 | :param requests.Response response: latest REST call response. 252 | :raises: BadResponse if response has no body and not status 202. 253 | """ 254 | self._raise_if_bad_http_status_and_method(response) 255 | code = response.status_code 256 | if code == 202: 257 | self.status = "InProgress" 258 | else: 259 | self.status = 'Succeeded' 260 | if self._is_empty(response): 261 | self.resource = None 262 | else: 263 | self.resource = self._deserialize(response) 264 | 265 | def get_status_from_resource(self, response): 266 | """Process the latest status update retrieved from the same URL as 267 | the previous request. 268 | 269 | :param requests.Response response: latest REST call response. 270 | :raises: BadResponse if status not 200 or 204. 271 | """ 272 | self._raise_if_bad_http_status_and_method(response) 273 | if self._is_empty(response): 274 | raise BadResponse('The response from long running operation ' 275 | 'does not contain a body.') 276 | 277 | status = self._get_provisioning_state(response) 278 | self.status = status or 'Succeeded' 279 | 280 | self.parse_resource(response) 281 | 282 | def parse_resource(self, response): 283 | """Assuming this response is a resource, use the deserialization callback to parse it. 284 | If body is empty, assuming no resource to return. 285 | """ 286 | self._raise_if_bad_http_status_and_method(response) 287 | if not self._is_empty(response): 288 | self.resource = self._deserialize(response) 289 | else: 290 | self.resource = None 291 | 292 | def get_status_from_async(self, response): 293 | """Process the latest status update retrieved from a 294 | 'azure-asyncoperation' header. 295 | 296 | :param requests.Response response: latest REST call response. 297 | :raises: BadResponse if response has no body, or body does not 298 | contain status. 299 | """ 300 | self._raise_if_bad_http_status_and_method(response) 301 | if self._is_empty(response): 302 | raise BadResponse('The response from long running operation ' 303 | 'does not contain a body.') 304 | 305 | self.status = self._get_async_status(response) 306 | if not self.status: 307 | raise BadResponse("No status found in body") 308 | 309 | # Status can contains information, see ARM spec: 310 | # https://github.com/Azure/azure-resource-manager-rpc/blob/master/v1.0/Addendum.md#operation-resource-format 311 | # "properties": { 312 | # /\* The resource provider can choose the values here, but it should only be 313 | # returned on a successful operation (status being "Succeeded"). \*/ 314 | #}, 315 | # So try to parse it 316 | try: 317 | self.resource = self._deserialize(response) 318 | except Exception: 319 | self.resource = None 320 | 321 | def set_async_url_if_present(self, response): 322 | async_url = get_header_url(response, 'azure-asyncoperation') 323 | if async_url: 324 | self.async_url = async_url 325 | location_url = get_header_url(response, 'location') 326 | if location_url: 327 | self.location_url = location_url 328 | 329 | def get_status_link(self): 330 | if self.async_url: 331 | return self.async_url 332 | elif self.location_url: 333 | return self.location_url 334 | elif self.method == "PUT": 335 | return self.initial_response.request.url 336 | else: 337 | raise BadResponse("Unable to find a valid status link for polling") 338 | 339 | 340 | class ARMPolling(PollingMethod): 341 | 342 | def __init__(self, timeout=30, lro_options=None, **operation_config): 343 | self._timeout = timeout 344 | self._operation = None # Will hold an instance of LongRunningOperation 345 | self._response = None # Will hold latest received response 346 | self._operation_config = operation_config 347 | self._lro_options = lro_options 348 | 349 | def status(self): 350 | """Return the current status as a string. 351 | :rtype: str 352 | """ 353 | if not self._operation: 354 | raise ValueError("set_initial_status was never called. Did you give this instance to a poller?") 355 | return self._operation.status 356 | 357 | def finished(self): 358 | """Is this polling finished? 359 | :rtype: bool 360 | """ 361 | return finished(self.status()) 362 | 363 | def resource(self): 364 | """Return the built resource. 365 | """ 366 | return self._operation.resource 367 | 368 | def initialize(self, client, initial_response, deserialization_callback): 369 | """Set the initial status of this LRO. 370 | 371 | :param initial_response: The initial response of the poller 372 | :raises: CloudError if initial status is incorrect LRO state 373 | """ 374 | self._client = client 375 | self._response = initial_response 376 | self._operation = LongRunningOperation(initial_response, deserialization_callback, self._lro_options) 377 | try: 378 | self._operation.set_initial_status(initial_response) 379 | except BadStatus: 380 | self._operation.status = 'Failed' 381 | raise CloudError(initial_response) 382 | except BadResponse as err: 383 | self._operation.status = 'Failed' 384 | raise CloudError(initial_response, str(err)) 385 | except OperationFailed: 386 | raise CloudError(initial_response) 387 | 388 | def run(self): 389 | try: 390 | self._poll() 391 | except BadStatus: 392 | self._operation.status = 'Failed' 393 | raise CloudError(self._response) 394 | 395 | except BadResponse as err: 396 | self._operation.status = 'Failed' 397 | raise CloudError(self._response, str(err)) 398 | 399 | except OperationFailed: 400 | raise CloudError(self._response) 401 | 402 | def _poll(self): 403 | """Poll status of operation so long as operation is incomplete and 404 | we have an endpoint to query. 405 | 406 | :param callable update_cmd: The function to call to retrieve the 407 | latest status of the long running operation. 408 | :raises: OperationFailed if operation status 'Failed' or 'Cancelled'. 409 | :raises: BadStatus if response status invalid. 410 | :raises: BadResponse if response invalid. 411 | """ 412 | 413 | while not self.finished(): 414 | self._delay() 415 | self.update_status() 416 | 417 | if failed(self._operation.status): 418 | raise OperationFailed("Operation failed or cancelled") 419 | 420 | elif self._operation.should_do_final_get(): 421 | if self._operation.method == 'POST' and self._operation.location_url: 422 | final_get_url = self._operation.location_url 423 | else: 424 | final_get_url = self._operation.initial_response.request.url 425 | self._response = self.request_status(final_get_url) 426 | self._operation.parse_resource(self._response) 427 | 428 | def _delay(self): 429 | """Check for a 'retry-after' header to set timeout, 430 | otherwise use configured timeout. 431 | """ 432 | if self._response is None: 433 | return 434 | if self._response.headers.get('retry-after'): 435 | time.sleep(int(self._response.headers['retry-after'])) 436 | else: 437 | time.sleep(self._timeout) 438 | 439 | def update_status(self): 440 | """Update the current status of the LRO. 441 | """ 442 | if self._operation.async_url: 443 | self._response = self.request_status(self._operation.async_url) 444 | self._operation.set_async_url_if_present(self._response) 445 | self._operation.get_status_from_async(self._response) 446 | elif self._operation.location_url: 447 | self._response = self.request_status(self._operation.location_url) 448 | self._operation.set_async_url_if_present(self._response) 449 | self._operation.get_status_from_location(self._response) 450 | elif self._operation.method == "PUT": 451 | initial_url = self._operation.initial_response.request.url 452 | self._response = self.request_status(initial_url) 453 | self._operation.set_async_url_if_present(self._response) 454 | self._operation.get_status_from_resource(self._response) 455 | else: 456 | raise BadResponse("Unable to find status link for polling.") 457 | 458 | def request_status(self, status_link): 459 | """Do a simple GET to this status link. 460 | 461 | This method re-inject 'x-ms-client-request-id'. 462 | 463 | :rtype: requests.Response 464 | """ 465 | request = self._client.get(status_link) 466 | # ARM requires to re-inject 'x-ms-client-request-id' while polling 467 | header_parameters = { 468 | 'x-ms-client-request-id': self._operation.initial_response.request.headers['x-ms-client-request-id'] 469 | } 470 | return self._client.send(request, header_parameters, stream=False, **self._operation_config) 471 | -------------------------------------------------------------------------------- /tests/test_arm_polling.py: -------------------------------------------------------------------------------- 1 | #-------------------------------------------------------------------------- 2 | # 3 | # Copyright (c) Microsoft Corporation. All rights reserved. 4 | # 5 | # The MIT License (MIT) 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the ""Software""), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | # THE SOFTWARE. 24 | # 25 | #-------------------------------------------------------------------------- 26 | 27 | import json 28 | import re 29 | import types 30 | import unittest 31 | try: 32 | from unittest import mock 33 | except ImportError: 34 | import mock 35 | 36 | import httpretty 37 | import pytest 38 | 39 | from requests import Request, Response 40 | 41 | from msrest import Deserializer, Configuration 42 | from msrest.service_client import ServiceClient 43 | from msrest.exceptions import DeserializationError 44 | from msrest.polling import LROPoller 45 | from msrestazure.azure_exceptions import CloudError 46 | from msrestazure.polling.arm_polling import ( 47 | LongRunningOperation, 48 | ARMPolling, 49 | BadStatus 50 | ) 51 | 52 | class SimpleResource: 53 | """An implementation of Python 3 SimpleNamespace. 54 | Used to deserialize resource objects from response bodies where 55 | no particular object type has been specified. 56 | """ 57 | 58 | def __init__(self, **kwargs): 59 | self.__dict__.update(kwargs) 60 | 61 | def __repr__(self): 62 | keys = sorted(self.__dict__) 63 | items = ("{}={!r}".format(k, self.__dict__[k]) for k in keys) 64 | return "{}({})".format(type(self).__name__, ", ".join(items)) 65 | 66 | def __eq__(self, other): 67 | return self.__dict__ == other.__dict__ 68 | 69 | class BadEndpointError(Exception): 70 | pass 71 | 72 | TEST_NAME = 'foo' 73 | RESPONSE_BODY = {'properties':{'provisioningState': 'InProgress'}} 74 | ASYNC_BODY = json.dumps({ 'status': 'Succeeded' }) 75 | ASYNC_URL = 'http://dummyurlFromAzureAsyncOPHeader_Return200' 76 | LOCATION_BODY = json.dumps({ 'name': TEST_NAME }) 77 | LOCATION_URL = 'http://dummyurlurlFromLocationHeader_Return200' 78 | RESOURCE_BODY = json.dumps({ 'name': TEST_NAME }) 79 | RESOURCE_URL = 'http://subscriptions/sub1/resourcegroups/g1/resourcetype1/resource1' 80 | ERROR = 'http://dummyurl_ReturnError' 81 | POLLING_STATUS = 200 82 | 83 | CLIENT = ServiceClient(None, Configuration("http://example.org")) 84 | def mock_send(client_self, request, header_parameters, stream): 85 | return TestArmPolling.mock_update(request.url, header_parameters) 86 | CLIENT.send = types.MethodType(mock_send, CLIENT) 87 | 88 | 89 | class TestArmPolling(object): 90 | 91 | convert = re.compile('([a-z0-9])([A-Z])') 92 | 93 | @staticmethod 94 | def mock_send(method, status, headers=None, body=None): 95 | if headers is None: 96 | headers = {} 97 | response = mock.create_autospec(Response) 98 | response.request = mock.create_autospec(Request) 99 | response.request.method = method 100 | response.request.url = RESOURCE_URL 101 | response.request.headers = { 102 | 'x-ms-client-request-id': '67f4dd4e-6262-45e1-8bed-5c45cf23b6d9' 103 | } 104 | response.status_code = status 105 | response.headers = headers 106 | response.headers.update({"content-type": "application/json; charset=utf8"}) 107 | content = body if body is not None else RESPONSE_BODY 108 | response.text = json.dumps(content) 109 | response.json = lambda: json.loads(response.text) 110 | return response 111 | 112 | @staticmethod 113 | def mock_update(url, headers=None): 114 | response = mock.create_autospec(Response) 115 | response.request = mock.create_autospec(Request) 116 | response.request.method = 'GET' 117 | response.headers = headers or {} 118 | response.headers.update({"content-type": "application/json; charset=utf8"}) 119 | 120 | if url == ASYNC_URL: 121 | response.request.url = url 122 | response.status_code = POLLING_STATUS 123 | response.text = ASYNC_BODY 124 | response.randomFieldFromPollAsyncOpHeader = None 125 | 126 | elif url == LOCATION_URL: 127 | response.request.url = url 128 | response.status_code = POLLING_STATUS 129 | response.text = LOCATION_BODY 130 | response.randomFieldFromPollLocationHeader = None 131 | 132 | elif url == ERROR: 133 | raise BadEndpointError("boom") 134 | 135 | elif url == RESOURCE_URL: 136 | response.request.url = url 137 | response.status_code = POLLING_STATUS 138 | response.text = RESOURCE_BODY 139 | 140 | else: 141 | raise Exception('URL does not match') 142 | response.json = lambda: json.loads(response.text) 143 | return response 144 | 145 | @staticmethod 146 | def mock_outputs(response): 147 | body = response.json() 148 | body = {TestArmPolling.convert.sub(r'\1_\2', k).lower(): v 149 | for k, v in body.items()} 150 | properties = body.setdefault('properties', {}) 151 | if 'name' in body: 152 | properties['name'] = body['name'] 153 | if properties: 154 | properties = {TestArmPolling.convert.sub(r'\1_\2', k).lower(): v 155 | for k, v in properties.items()} 156 | del body['properties'] 157 | body.update(properties) 158 | resource = SimpleResource(**body) 159 | else: 160 | raise DeserializationError("Impossible to deserialize") 161 | resource = SimpleResource(**body) 162 | return resource 163 | 164 | def test_long_running_put(self): 165 | #TODO: Test custom header field 166 | 167 | # Test throw on non LRO related status code 168 | response = TestArmPolling.mock_send('PUT', 1000, {}) 169 | op = LongRunningOperation(response, lambda x:None) 170 | with pytest.raises(BadStatus): 171 | op.set_initial_status(response) 172 | with pytest.raises(CloudError): 173 | LROPoller(CLIENT, response, 174 | TestArmPolling.mock_outputs, 175 | ARMPolling(0)).result() 176 | 177 | # Test with no polling necessary 178 | response_body = { 179 | 'properties':{'provisioningState': 'Succeeded'}, 180 | 'name': TEST_NAME 181 | } 182 | response = TestArmPolling.mock_send( 183 | 'PUT', 201, 184 | {}, response_body 185 | ) 186 | def no_update_allowed(url, headers=None): 187 | raise ValueError("Should not try to update") 188 | poll = LROPoller(CLIENT, response, 189 | TestArmPolling.mock_outputs, 190 | ARMPolling(0) 191 | ) 192 | assert poll.result().name == TEST_NAME 193 | assert not hasattr(poll._polling_method._response, 'randomFieldFromPollAsyncOpHeader') 194 | 195 | # Test polling from azure-asyncoperation header 196 | response = TestArmPolling.mock_send( 197 | 'PUT', 201, 198 | {'azure-asyncoperation': ASYNC_URL}) 199 | poll = LROPoller(CLIENT, response, 200 | TestArmPolling.mock_outputs, 201 | ARMPolling(0)) 202 | assert poll.result().name == TEST_NAME 203 | assert not hasattr(poll._polling_method._response, 'randomFieldFromPollAsyncOpHeader') 204 | 205 | # Test polling location header 206 | response = TestArmPolling.mock_send( 207 | 'PUT', 201, 208 | {'location': LOCATION_URL}) 209 | poll = LROPoller(CLIENT, response, 210 | TestArmPolling.mock_outputs, 211 | ARMPolling(0)) 212 | assert poll.result().name == TEST_NAME 213 | assert poll._polling_method._response.randomFieldFromPollLocationHeader is None 214 | 215 | # Test polling initial payload invalid (SQLDb) 216 | response_body = {} # Empty will raise 217 | response = TestArmPolling.mock_send( 218 | 'PUT', 201, 219 | {'location': LOCATION_URL}, response_body) 220 | poll = LROPoller(CLIENT, response, 221 | TestArmPolling.mock_outputs, 222 | ARMPolling(0)) 223 | assert poll.result().name == TEST_NAME 224 | assert poll._polling_method._response.randomFieldFromPollLocationHeader is None 225 | 226 | # Test fail to poll from azure-asyncoperation header 227 | response = TestArmPolling.mock_send( 228 | 'PUT', 201, 229 | {'azure-asyncoperation': ERROR}) 230 | with pytest.raises(BadEndpointError): 231 | poll = LROPoller(CLIENT, response, 232 | TestArmPolling.mock_outputs, 233 | ARMPolling(0)).result() 234 | 235 | # Test fail to poll from location header 236 | response = TestArmPolling.mock_send( 237 | 'PUT', 201, 238 | {'location': ERROR}) 239 | with pytest.raises(BadEndpointError): 240 | poll = LROPoller(CLIENT, response, 241 | TestArmPolling.mock_outputs, 242 | ARMPolling(0)).result() 243 | 244 | def test_long_running_patch(self): 245 | 246 | # Test polling from location header 247 | response = TestArmPolling.mock_send( 248 | 'PATCH', 202, 249 | {'location': LOCATION_URL}, 250 | body={'properties':{'provisioningState': 'Succeeded'}}) 251 | poll = LROPoller(CLIENT, response, 252 | TestArmPolling.mock_outputs, 253 | ARMPolling(0)) 254 | assert poll.result().name == TEST_NAME 255 | assert poll._polling_method._response.randomFieldFromPollLocationHeader is None 256 | 257 | # Test polling from azure-asyncoperation header 258 | response = TestArmPolling.mock_send( 259 | 'PATCH', 202, 260 | {'azure-asyncoperation': ASYNC_URL}, 261 | body={'properties':{'provisioningState': 'Succeeded'}}) 262 | poll = LROPoller(CLIENT, response, 263 | TestArmPolling.mock_outputs, 264 | ARMPolling(0)) 265 | assert poll.result().name == TEST_NAME 266 | assert not hasattr(poll._polling_method._response, 'randomFieldFromPollAsyncOpHeader') 267 | 268 | # Test polling from location header 269 | response = TestArmPolling.mock_send( 270 | 'PATCH', 200, 271 | {'location': LOCATION_URL}, 272 | body={'properties':{'provisioningState': 'Succeeded'}}) 273 | poll = LROPoller(CLIENT, response, 274 | TestArmPolling.mock_outputs, 275 | ARMPolling(0)) 276 | assert poll.result().name == TEST_NAME 277 | assert poll._polling_method._response.randomFieldFromPollLocationHeader is None 278 | 279 | # Test polling from azure-asyncoperation header 280 | response = TestArmPolling.mock_send( 281 | 'PATCH', 200, 282 | {'azure-asyncoperation': ASYNC_URL}, 283 | body={'properties':{'provisioningState': 'Succeeded'}}) 284 | poll = LROPoller(CLIENT, response, 285 | TestArmPolling.mock_outputs, 286 | ARMPolling(0)) 287 | assert poll.result().name == TEST_NAME 288 | assert not hasattr(poll._polling_method._response, 'randomFieldFromPollAsyncOpHeader') 289 | 290 | # Test fail to poll from azure-asyncoperation header 291 | response = TestArmPolling.mock_send( 292 | 'PATCH', 202, 293 | {'azure-asyncoperation': ERROR}) 294 | with pytest.raises(BadEndpointError): 295 | poll = LROPoller(CLIENT, response, 296 | TestArmPolling.mock_outputs, 297 | ARMPolling(0)).result() 298 | 299 | # Test fail to poll from location header 300 | response = TestArmPolling.mock_send( 301 | 'PATCH', 202, 302 | {'location': ERROR}) 303 | with pytest.raises(BadEndpointError): 304 | poll = LROPoller(CLIENT, response, 305 | TestArmPolling.mock_outputs, 306 | ARMPolling(0)).result() 307 | 308 | def test_long_running_delete(self): 309 | # Test polling from azure-asyncoperation header 310 | response = TestArmPolling.mock_send( 311 | 'DELETE', 202, 312 | {'azure-asyncoperation': ASYNC_URL}, 313 | body="" 314 | ) 315 | poll = LROPoller(CLIENT, response, 316 | TestArmPolling.mock_outputs, 317 | ARMPolling(0)) 318 | poll.wait() 319 | assert poll.result() is None 320 | assert poll._polling_method._response.randomFieldFromPollAsyncOpHeader is None 321 | 322 | @httpretty.activate 323 | def test_long_running_post(self): 324 | 325 | # Test POST LRO with both Location and Azure-AsyncOperation 326 | 327 | # The initial response contains both Location and Azure-AsyncOperation, a 202 and no Body 328 | response = TestArmPolling.mock_send( 329 | 'POST', 330 | 202, 331 | { 332 | 'location': 'http://example.org/location', 333 | 'azure-asyncoperation': 'http://example.org/async_monitor', 334 | }, 335 | '' 336 | ) 337 | 338 | class TestServiceClient(ServiceClient): 339 | def __init__(self): 340 | ServiceClient.__init__(self, None, Configuration("http://example.org")) 341 | 342 | def send(self, request, headers=None, content=None, **config): 343 | assert request.method == 'GET' 344 | 345 | if request.url == 'http://example.org/location': 346 | return TestArmPolling.mock_send( 347 | 'GET', 348 | 200, 349 | body={'location_result': True} 350 | ) 351 | elif request.url == 'http://example.org/async_monitor': 352 | return TestArmPolling.mock_send( 353 | 'GET', 354 | 200, 355 | body={'status': 'Succeeded'} 356 | ) 357 | else: 358 | pytest.fail("No other query allowed") 359 | 360 | def deserialization_cb(response): 361 | return response.json() 362 | 363 | # Test 1, LRO options with Location final state 364 | poll = LROPoller( 365 | TestServiceClient(), 366 | response, 367 | deserialization_cb, 368 | ARMPolling(0, lro_options={"final-state-via": "location"})) 369 | result = poll.result() 370 | assert result['location_result'] == True 371 | 372 | # Test 2, LRO options with Azure-AsyncOperation final state 373 | poll = LROPoller( 374 | TestServiceClient(), 375 | response, 376 | deserialization_cb, 377 | ARMPolling(0, lro_options={"final-state-via": "azure-async-operation"})) 378 | result = poll.result() 379 | assert result['status'] == 'Succeeded' 380 | 381 | # Test 3, backward compat (no options, means "azure-async-operation") 382 | poll = LROPoller( 383 | TestServiceClient(), 384 | response, 385 | deserialization_cb, 386 | ARMPolling(0)) 387 | result = poll.result() 388 | assert result['status'] == 'Succeeded' 389 | 390 | # Test 4, location has no body 391 | 392 | class TestServiceClientNoBody(ServiceClient): 393 | def __init__(self): 394 | ServiceClient.__init__(self, None, Configuration("http://example.org")) 395 | 396 | def send(self, request, headers=None, content=None, **config): 397 | assert request.method == 'GET' 398 | 399 | if request.url == 'http://example.org/location': 400 | return TestArmPolling.mock_send( 401 | 'GET', 402 | 200, 403 | body="" 404 | ) 405 | elif request.url == 'http://example.org/async_monitor': 406 | return TestArmPolling.mock_send( 407 | 'GET', 408 | 200, 409 | body={'status': 'Succeeded'} 410 | ) 411 | else: 412 | pytest.fail("No other query allowed") 413 | 414 | poll = LROPoller( 415 | TestServiceClientNoBody(), 416 | response, 417 | deserialization_cb, 418 | ARMPolling(0, lro_options={"final-state-via": "location"})) 419 | result = poll.result() 420 | assert result is None 421 | 422 | 423 | # Former oooooold tests to refactor one day to something more readble 424 | 425 | # Test throw on non LRO related status code 426 | response = TestArmPolling.mock_send('POST', 201, {}) 427 | op = LongRunningOperation(response, lambda x:None) 428 | with pytest.raises(BadStatus): 429 | op.set_initial_status(response) 430 | with pytest.raises(CloudError): 431 | LROPoller(CLIENT, response, 432 | TestArmPolling.mock_outputs, 433 | ARMPolling(0)).result() 434 | 435 | # Test polling from azure-asyncoperation header 436 | response = TestArmPolling.mock_send( 437 | 'POST', 202, 438 | {'azure-asyncoperation': ASYNC_URL}, 439 | body={'properties':{'provisioningState': 'Succeeded'}}) 440 | poll = LROPoller(CLIENT, response, 441 | TestArmPolling.mock_outputs, 442 | ARMPolling(0)) 443 | poll.wait() 444 | #self.assertIsNone(poll.result()) 445 | assert poll._polling_method._response.randomFieldFromPollAsyncOpHeader is None 446 | 447 | # Test polling from location header 448 | response = TestArmPolling.mock_send( 449 | 'POST', 202, 450 | {'location': LOCATION_URL}, 451 | body={'properties':{'provisioningState': 'Succeeded'}}) 452 | poll = LROPoller(CLIENT, response, 453 | TestArmPolling.mock_outputs, 454 | ARMPolling(0)) 455 | assert poll.result().name == TEST_NAME 456 | assert poll._polling_method._response.randomFieldFromPollLocationHeader is None 457 | 458 | # Test fail to poll from azure-asyncoperation header 459 | response = TestArmPolling.mock_send( 460 | 'POST', 202, 461 | {'azure-asyncoperation': ERROR}) 462 | with pytest.raises(BadEndpointError): 463 | poll = LROPoller(CLIENT, response, 464 | TestArmPolling.mock_outputs, 465 | ARMPolling(0)).result() 466 | 467 | # Test fail to poll from location header 468 | response = TestArmPolling.mock_send( 469 | 'POST', 202, 470 | {'location': ERROR}) 471 | with pytest.raises(BadEndpointError): 472 | poll = LROPoller(CLIENT, response, 473 | TestArmPolling.mock_outputs, 474 | ARMPolling(0)).result() 475 | 476 | def test_long_running_negative(self): 477 | global LOCATION_BODY 478 | global POLLING_STATUS 479 | 480 | # Test LRO PUT throws for invalid json 481 | LOCATION_BODY = '{' 482 | response = TestArmPolling.mock_send( 483 | 'POST', 202, 484 | {'location': LOCATION_URL}) 485 | poll = LROPoller( 486 | CLIENT, 487 | response, 488 | TestArmPolling.mock_outputs, 489 | ARMPolling(0) 490 | ) 491 | with pytest.raises(DeserializationError): 492 | poll.wait() 493 | 494 | LOCATION_BODY = '{\'"}' 495 | response = TestArmPolling.mock_send( 496 | 'POST', 202, 497 | {'location': LOCATION_URL}) 498 | poll = LROPoller(CLIENT, response, 499 | TestArmPolling.mock_outputs, 500 | ARMPolling(0)) 501 | with pytest.raises(DeserializationError): 502 | poll.wait() 503 | 504 | LOCATION_BODY = '{' 505 | POLLING_STATUS = 203 506 | response = TestArmPolling.mock_send( 507 | 'POST', 202, 508 | {'location': LOCATION_URL}) 509 | poll = LROPoller(CLIENT, response, 510 | TestArmPolling.mock_outputs, 511 | ARMPolling(0)) 512 | with pytest.raises(CloudError): # TODO: Node.js raises on deserialization 513 | poll.wait() 514 | 515 | LOCATION_BODY = json.dumps({ 'name': TEST_NAME }) 516 | POLLING_STATUS = 200 517 | 518 | -------------------------------------------------------------------------------- /msrestazure/azure_operation.py: -------------------------------------------------------------------------------- 1 | # -------------------------------------------------------------------------- 2 | # 3 | # Copyright (c) Microsoft Corporation. All rights reserved. 4 | # 5 | # The MIT License (MIT) 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the ""Software""), to 9 | # deal in the Software without restriction, including without limitation the 10 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 11 | # sell copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 22 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 23 | # IN THE SOFTWARE. 24 | # 25 | # -------------------------------------------------------------------------- 26 | 27 | import re 28 | import threading 29 | import time 30 | import uuid 31 | try: 32 | from urlparse import urlparse 33 | except ImportError: 34 | from urllib.parse import urlparse 35 | 36 | from msrest.exceptions import DeserializationError, ClientException 37 | from msrestazure.azure_exceptions import CloudError 38 | 39 | 40 | FINISHED = frozenset(['succeeded', 'canceled', 'failed']) 41 | FAILED = frozenset(['canceled', 'failed']) 42 | SUCCEEDED = frozenset(['succeeded']) 43 | 44 | 45 | def finished(status): 46 | if hasattr(status, 'value'): 47 | status = status.value 48 | return str(status).lower() in FINISHED 49 | 50 | 51 | def failed(status): 52 | if hasattr(status, 'value'): 53 | status = status.value 54 | return str(status).lower() in FAILED 55 | 56 | 57 | def succeeded(status): 58 | if hasattr(status, 'value'): 59 | status = status.value 60 | return str(status).lower() in SUCCEEDED 61 | 62 | 63 | def _validate(url): 64 | """Validate a url. 65 | 66 | :param str url: Polling URL extracted from response header. 67 | :raises: ValueError if URL has no scheme or host. 68 | """ 69 | if url is None: 70 | return 71 | parsed = urlparse(url) 72 | if not parsed.scheme or not parsed.netloc: 73 | raise ValueError("Invalid URL header") 74 | 75 | def _get_header_url(response, header_name): 76 | """Get a URL from a header requests. 77 | 78 | :param requests.Response response: REST call response. 79 | :param str header_name: Header name. 80 | :returns: URL if not None AND valid, None otherwise 81 | """ 82 | url = response.headers.get(header_name) 83 | try: 84 | _validate(url) 85 | except ValueError: 86 | return None 87 | else: 88 | return url 89 | 90 | class BadStatus(Exception): 91 | pass 92 | 93 | 94 | class BadResponse(Exception): 95 | pass 96 | 97 | 98 | class OperationFailed(Exception): 99 | pass 100 | 101 | 102 | class SimpleResource: 103 | """An implementation of Python 3 SimpleNamespace. 104 | Used to deserialize resource objects from response bodies where 105 | no particular object type has been specified. 106 | """ 107 | 108 | def __init__(self, **kwargs): 109 | self.__dict__.update(kwargs) 110 | 111 | def __repr__(self): 112 | keys = sorted(self.__dict__) 113 | items = ("{}={!r}".format(k, self.__dict__[k]) for k in keys) 114 | return "{}({})".format(type(self).__name__, ", ".join(items)) 115 | 116 | def __eq__(self, other): 117 | return self.__dict__ == other.__dict__ 118 | 119 | 120 | class LongRunningOperation(object): 121 | """LongRunningOperation 122 | Provides default logic for interpreting operation responses 123 | and status updates. 124 | """ 125 | _convert = re.compile('([a-z0-9])([A-Z])') 126 | 127 | def __init__(self, response, outputs): 128 | self.method = response.request.method 129 | self.status = "" 130 | self.resource = None 131 | self.get_outputs = outputs 132 | self.async_url = None 133 | self.location_url = None 134 | self.initial_status_code = None 135 | 136 | def _raise_if_bad_http_status_and_method(self, response): 137 | """Check response status code is valid for a Put or Patch 138 | request. Must be 200, 201, 202, or 204. 139 | 140 | :raises: BadStatus if invalid status. 141 | """ 142 | code = response.status_code 143 | if code in {200, 202} or \ 144 | (code == 201 and self.method in {'PUT', 'PATCH'}) or \ 145 | (code == 204 and self.method in {'DELETE', 'POST'}): 146 | return 147 | raise BadStatus( 148 | "Invalid return status for {!r} operation".format(self.method)) 149 | 150 | def _is_empty(self, response): 151 | """Check if response body contains meaningful content. 152 | 153 | :rtype: bool 154 | :raises: DeserializationError if response body contains invalid 155 | json data. 156 | """ 157 | if not response.content: 158 | return True 159 | try: 160 | body = response.json() 161 | return not body 162 | except ValueError: 163 | raise DeserializationError( 164 | "Error occurred in deserializing the response body.") 165 | 166 | def _deserialize(self, response): 167 | """Attempt to deserialize resource from response. 168 | 169 | :param requests.Response response: latest REST call response. 170 | """ 171 | # Hacking response with initial status_code 172 | previous_status = response.status_code 173 | response.status_code = self.initial_status_code 174 | resource = self.get_outputs(response) 175 | response.status_code = previous_status 176 | 177 | # Hack for Storage or SQL, to workaround the bug in the Python generator 178 | if resource is None: 179 | previous_status = response.status_code 180 | for status_code_to_test in [200, 201]: 181 | try: 182 | response.status_code = status_code_to_test 183 | resource = self.get_outputs(response) 184 | except ClientException: 185 | pass 186 | else: 187 | return resource 188 | finally: 189 | response.status_code = previous_status 190 | return resource 191 | 192 | def _get_async_status(self, response): 193 | """Attempt to find status info in response body. 194 | 195 | :param requests.Response response: latest REST call response. 196 | :rtype: str 197 | :returns: Status if found, else 'None'. 198 | """ 199 | if self._is_empty(response): 200 | return None 201 | body = response.json() 202 | return body.get('status') 203 | 204 | def _get_provisioning_state(self, response): 205 | """ 206 | Attempt to get provisioning state from resource. 207 | :param requests.Response response: latest REST call response. 208 | :returns: Status if found, else 'None'. 209 | """ 210 | if self._is_empty(response): 211 | return None 212 | body = response.json() 213 | return body.get("properties", {}).get("provisioningState") 214 | 215 | def should_do_final_get(self): 216 | """Check whether the polling should end doing a final GET. 217 | 218 | :param requests.Response response: latest REST call response. 219 | :rtype: bool 220 | """ 221 | return (self.async_url or not self.resource) and \ 222 | self.method in {'PUT', 'PATCH'} 223 | 224 | def set_initial_status(self, response): 225 | """Process first response after initiating long running 226 | operation and set self.status attribute. 227 | 228 | :param requests.Response response: initial REST call response. 229 | """ 230 | self._raise_if_bad_http_status_and_method(response) 231 | 232 | if self._is_empty(response): 233 | self.resource = None 234 | else: 235 | try: 236 | self.resource = self.get_outputs(response) 237 | except DeserializationError: 238 | self.resource = None 239 | 240 | self.set_async_url_if_present(response) 241 | 242 | if response.status_code in {200, 201, 202, 204}: 243 | self.initial_status_code = response.status_code 244 | if self.async_url or self.location_url or response.status_code == 202: 245 | self.status = 'InProgress' 246 | elif response.status_code == 201: 247 | status = self._get_provisioning_state(response) 248 | self.status = status or 'InProgress' 249 | elif response.status_code == 200: 250 | status = self._get_provisioning_state(response) 251 | self.status = status or 'Succeeded' 252 | elif response.status_code == 204: 253 | self.status = 'Succeeded' 254 | self.resource = None 255 | else: 256 | raise OperationFailed("Invalid status found") 257 | return 258 | raise OperationFailed("Operation failed or cancelled") 259 | 260 | def get_status_from_location(self, response): 261 | """Process the latest status update retrieved from a 'location' 262 | header. 263 | 264 | :param requests.Response response: latest REST call response. 265 | :raises: BadResponse if response has no body and not status 202. 266 | """ 267 | self._raise_if_bad_http_status_and_method(response) 268 | code = response.status_code 269 | if code == 202: 270 | self.status = "InProgress" 271 | else: 272 | self.status = 'Succeeded' 273 | if self._is_empty(response): 274 | self.resource = None 275 | else: 276 | self.resource = self._deserialize(response) 277 | 278 | def get_status_from_resource(self, response): 279 | """Process the latest status update retrieved from the same URL as 280 | the previous request. 281 | 282 | :param requests.Response response: latest REST call response. 283 | :raises: BadResponse if status not 200 or 204. 284 | """ 285 | self._raise_if_bad_http_status_and_method(response) 286 | if self._is_empty(response): 287 | raise BadResponse('The response from long running operation ' 288 | 'does not contain a body.') 289 | 290 | status = self._get_provisioning_state(response) 291 | self.status = status or 'Succeeded' 292 | 293 | self.resource = self._deserialize(response) 294 | 295 | def get_status_from_async(self, response): 296 | """Process the latest status update retrieved from a 297 | 'azure-asyncoperation' header. 298 | 299 | :param requests.Response response: latest REST call response. 300 | :raises: BadResponse if response has no body, or body does not 301 | contain status. 302 | """ 303 | self._raise_if_bad_http_status_and_method(response) 304 | if self._is_empty(response): 305 | raise BadResponse('The response from long running operation ' 306 | 'does not contain a body.') 307 | 308 | self.status = self._get_async_status(response) 309 | if not self.status: 310 | raise BadResponse("No status found in body") 311 | 312 | # Status can contains information, see ARM spec: 313 | # https://github.com/Azure/azure-resource-manager-rpc/blob/master/v1.0/Addendum.md#operation-resource-format 314 | # "properties": { 315 | # /\* The resource provider can choose the values here, but it should only be 316 | # returned on a successful operation (status being "Succeeded"). \*/ 317 | #}, 318 | # So try to parse it 319 | try: 320 | self.resource = self.get_outputs(response) 321 | except Exception: 322 | self.resource = None 323 | 324 | def set_async_url_if_present(self, response): 325 | async_url = _get_header_url(response, 'azure-asyncoperation') 326 | if async_url: 327 | self.async_url = async_url 328 | 329 | location_url = _get_header_url(response, 'location') 330 | if location_url: 331 | self.location_url = location_url 332 | 333 | 334 | class AzureOperationPoller(object): 335 | """Initiates long running operation and polls status in separate 336 | thread. 337 | 338 | :param callable send_cmd: The API request to initiate the operation. 339 | :param callable update_cmd: The API reuqest to check the status of 340 | the operation. 341 | :param callable output_cmd: The function to deserialize the resource 342 | of the operation. 343 | :param int timeout: Time in seconds to wait between status calls, 344 | default is 30. 345 | """ 346 | 347 | def __init__(self, send_cmd, output_cmd, update_cmd, timeout=30): 348 | self._timeout = timeout 349 | self._callbacks = [] 350 | 351 | try: 352 | self._response = send_cmd() 353 | self._operation = LongRunningOperation(self._response, output_cmd) 354 | self._operation.set_initial_status(self._response) 355 | except BadStatus: 356 | self._operation.status = 'Failed' 357 | raise CloudError(self._response) 358 | except BadResponse as err: 359 | self._operation.status = 'Failed' 360 | raise CloudError(self._response, str(err)) 361 | except OperationFailed: 362 | raise CloudError(self._response) 363 | 364 | self._thread = None 365 | self._done = None 366 | self._exception = None 367 | if not finished(self.status()): 368 | self._done = threading.Event() 369 | self._thread = threading.Thread( 370 | target=self._start, 371 | name="AzureOperationPoller({})".format(uuid.uuid4()), 372 | args=(update_cmd,)) 373 | self._thread.daemon = True 374 | self._thread.start() 375 | 376 | def _start(self, update_cmd): 377 | """Start the long running operation. 378 | On completion, runs any callbacks. 379 | 380 | :param callable update_cmd: The API reuqest to check the status of 381 | the operation. 382 | """ 383 | try: 384 | self._poll(update_cmd) 385 | 386 | except BadStatus: 387 | self._operation.status = 'Failed' 388 | self._exception = CloudError(self._response) 389 | 390 | except BadResponse as err: 391 | self._operation.status = 'Failed' 392 | self._exception = CloudError(self._response, str(err)) 393 | 394 | except OperationFailed: 395 | self._exception = CloudError(self._response) 396 | 397 | except Exception as err: 398 | self._exception = err 399 | 400 | finally: 401 | self._done.set() 402 | 403 | callbacks, self._callbacks = self._callbacks, [] 404 | while callbacks: 405 | for call in callbacks: 406 | call(self._operation) 407 | callbacks, self._callbacks = self._callbacks, [] 408 | 409 | def _delay(self): 410 | """Check for a 'retry-after' header to set timeout, 411 | otherwise use configured timeout. 412 | """ 413 | if self._response is None: 414 | return 415 | if self._response.headers.get('retry-after'): 416 | time.sleep(int(self._response.headers['retry-after'])) 417 | else: 418 | time.sleep(self._timeout) 419 | 420 | def _polling_cookie(self): 421 | """Collect retry cookie - we only want to do this for the test server 422 | at this point, unless we implement a proper cookie policy. 423 | 424 | :returns: Dictionary containing a cookie header if required, 425 | otherwise an empty dictionary. 426 | """ 427 | parsed_url = urlparse(self._response.request.url) 428 | host = parsed_url.hostname.strip('.') 429 | if host == 'localhost': 430 | return {'cookie': self._response.headers.get('set-cookie', '')} 431 | return {} 432 | 433 | def _poll(self, update_cmd): 434 | """Poll status of operation so long as operation is incomplete and 435 | we have an endpoint to query. 436 | 437 | :param callable update_cmd: The function to call to retrieve the 438 | latest status of the long running operation. 439 | :raises: OperationFailed if operation status 'Failed' or 'Cancelled'. 440 | :raises: BadStatus if response status invalid. 441 | :raises: BadResponse if response invalid. 442 | """ 443 | initial_url = self._response.request.url 444 | 445 | while not finished(self.status()): 446 | self._delay() 447 | headers = self._polling_cookie() 448 | 449 | if self._operation.async_url: 450 | self._response = update_cmd( 451 | self._operation.async_url, headers) 452 | self._operation.set_async_url_if_present(self._response) 453 | self._operation.get_status_from_async( 454 | self._response) 455 | elif self._operation.location_url: 456 | self._response = update_cmd( 457 | self._operation.location_url, headers) 458 | self._operation.set_async_url_if_present(self._response) 459 | self._operation.get_status_from_location( 460 | self._response) 461 | elif self._operation.method == "PUT": 462 | self._response = update_cmd(initial_url, headers) 463 | self._operation.set_async_url_if_present(self._response) 464 | self._operation.get_status_from_resource( 465 | self._response) 466 | else: 467 | raise BadResponse( 468 | 'Location header is missing from long running operation.') 469 | 470 | if failed(self._operation.status): 471 | raise OperationFailed("Operation failed or cancelled") 472 | elif self._operation.should_do_final_get(): 473 | self._response = update_cmd(initial_url) 474 | self._operation.get_status_from_resource( 475 | self._response) 476 | 477 | def status(self): 478 | """Returns the current status string. 479 | 480 | :returns: The current status string 481 | :rtype: str 482 | """ 483 | return self._operation.status 484 | 485 | def result(self, timeout=None): 486 | """Return the result of the long running operation, or 487 | the result available after the specified timeout. 488 | 489 | :returns: The deserialized resource of the long running operation, 490 | if one is available. 491 | :raises CloudError: Server problem with the query. 492 | """ 493 | self.wait(timeout) 494 | return self._operation.resource 495 | 496 | def wait(self, timeout=None): 497 | """Wait on the long running operation for a specified length 498 | of time. 499 | 500 | :param int timeout: Perion of time to wait for the long running 501 | operation to complete. 502 | :raises CloudError: Server problem with the query. 503 | """ 504 | if self._thread is None: 505 | return 506 | self._thread.join(timeout=timeout) 507 | try: 508 | raise self._exception 509 | except TypeError: 510 | pass 511 | 512 | def done(self): 513 | """Check status of the long running operation. 514 | 515 | :returns: 'True' if the process has completed, else 'False'. 516 | """ 517 | return self._thread is None or not self._thread.is_alive() 518 | 519 | def add_done_callback(self, func): 520 | """Add callback function to be run once the long running operation 521 | has completed - regardless of the status of the operation. 522 | 523 | :param callable func: Callback function that takes at least one 524 | argument, a completed LongRunningOperation. 525 | :raises: ValueError if the long running operation has already 526 | completed. 527 | """ 528 | if self._done is None or self._done.is_set(): 529 | raise ValueError("Process is complete.") 530 | self._callbacks.append(func) 531 | 532 | def remove_done_callback(self, func): 533 | """Remove a callback from the long running operation. 534 | 535 | :param callable func: The function to be removed from the callbacks. 536 | :raises: ValueError if the long running operation has already 537 | completed. 538 | """ 539 | if self._done is None or self._done.is_set(): 540 | raise ValueError("Process is complete.") 541 | self._callbacks = [c for c in self._callbacks if c != func] 542 | --------------------------------------------------------------------------------