├── 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 |
--------------------------------------------------------------------------------