├── .gitignore ├── API Samples.ipynb ├── LICENSE ├── README.md ├── contribute.md ├── requirements.dev.txt ├── requirements.jupyter.txt ├── requirements.txt └── src ├── .flake8 ├── config.py ├── exceptions.py ├── hacks.py ├── http_logging.py ├── runner.py ├── runner_lib.py ├── samples ├── __init__.py ├── build.py ├── core.py ├── git.py ├── test.py └── work_item_tracking.py ├── tests ├── __init__.py ├── test_http_logging.py └── test_runner.py └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Project 2 | azure-devops-runner-config.json 3 | vsts-runner-config.json 4 | 5 | # Editors 6 | .vscode/ 7 | 8 | # Byte-compiled / optimized / DLL files 9 | __pycache__/ 10 | *.py[cod] 11 | *$py.class 12 | 13 | # C extensions 14 | *.so 15 | 16 | # Distribution / packaging 17 | .Python 18 | env/ 19 | build/ 20 | develop-eggs/ 21 | dist/ 22 | downloads/ 23 | eggs/ 24 | .eggs/ 25 | lib/ 26 | lib64/ 27 | parts/ 28 | sdist/ 29 | var/ 30 | wheels/ 31 | *.egg-info/ 32 | .installed.cfg 33 | *.egg 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # pyenv 82 | .python-version 83 | 84 | # celery beat schedule file 85 | celerybeat-schedule 86 | 87 | # SageMath parsed files 88 | *.sage.py 89 | 90 | # dotenv 91 | .env 92 | 93 | # virtualenv 94 | .venv 95 | venv/ 96 | ENV/ 97 | env*/ 98 | 99 | # Spyder project settings 100 | .spyderproject 101 | .spyproject 102 | 103 | # Rope project settings 104 | .ropeproject 105 | 106 | # mkdocs documentation 107 | /site 108 | 109 | # mypy 110 | .mypy_cache/ 111 | -------------------------------------------------------------------------------- /API Samples.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "\"\"\"\n", 10 | "Azure DevOps\n", 11 | "Python API samples\n", 12 | "\n", 13 | "Click \"Run\" in the Jupyter menu to get started.\n", 14 | "\n", 15 | " vSTs\n", 16 | " vSTSVSTSv\n", 17 | " vSTSVSTSVST\n", 18 | " VSTS vSTSVSTSVSTSV\n", 19 | " VSTSVS vSTSVSTSV STSVS\n", 20 | " VSTSVSTSvsTSVSTSVS TSVST\n", 21 | " VS tSVSTSVSTSv STSVS\n", 22 | " VS tSVSTSVST SVSTS\n", 23 | " VS tSVSTSVSTSVSts VSTSV\n", 24 | " VSTSVST SVSTSVSTs VSTSV\n", 25 | " VSTSv STSVSTSVSTSVS\n", 26 | " VSTSVSTSVST\n", 27 | " VSTSVSTs\n", 28 | " VSTs (TM)\n", 29 | "\"\"\"\n", 30 | "\n", 31 | "import sys\n", 32 | "# tell python to look in .\\src for loading modules\n", 33 | "sys.path.insert(1, 'src')\n", 34 | "\n", 35 | "from IPython.display import display\n", 36 | "import ipywidgets as widgets\n", 37 | "\n", 38 | "import runner\n", 39 | "import runner_lib\n", 40 | "\n", 41 | "\n", 42 | "def build_areas_and_resources():\n", 43 | " value = {\n", 44 | " 'all': ['all',],\n", 45 | " }\n", 46 | " \n", 47 | " for area in runner_lib.discovered_samples.keys():\n", 48 | " value[area] = ['all',]\n", 49 | " value[area].extend(runner_lib.discovered_samples[area].keys())\n", 50 | " \n", 51 | " return value\n", 52 | "\n", 53 | "areas_and_resources = build_areas_and_resources()\n", 54 | "\n", 55 | "\n", 56 | "# build and display widgets\n", 57 | "url_widget = widgets.Text(\n", 58 | " value='',\n", 59 | " placeholder='https://fabrikam.visualstudio.com',\n", 60 | " description='Account URL'\n", 61 | ")\n", 62 | "pat_widget = widgets.Text(\n", 63 | " value='',\n", 64 | " placeholder='VSTS PAT',\n", 65 | " description='PAT'\n", 66 | ")\n", 67 | "area_widget = widgets.ToggleButtons(\n", 68 | " options=areas_and_resources.keys(),\n", 69 | " description='Area'\n", 70 | ")\n", 71 | "resource_widget = widgets.ToggleButtons(\n", 72 | " options=areas_and_resources[area_widget.value],\n", 73 | " description='Resource'\n", 74 | ")\n", 75 | "run_widget = widgets.Button(\n", 76 | " description='Run samples',\n", 77 | " button_style='success',\n", 78 | " icon='play'\n", 79 | ")\n", 80 | "\n", 81 | "def on_area_change(change):\n", 82 | " resource_widget.options = areas_and_resources[change['new']]\n", 83 | "\n", 84 | "area_widget.observe(on_area_change, names='value')\n", 85 | "\n", 86 | "def on_run_click(b):\n", 87 | " if not url_widget.value or not pat_widget.value:\n", 88 | " print('You must specify a URL and PAT to run these samples.')\n", 89 | " return\n", 90 | " \n", 91 | " print('running samples...')\n", 92 | " print('-------------------------')\n", 93 | " print()\n", 94 | " runner.main(url=url_widget.value,\n", 95 | " auth_token=pat_widget.value,\n", 96 | " area=area_widget.value,\n", 97 | " resource=resource_widget.value\n", 98 | " )\n", 99 | " print()\n", 100 | " print('-------------------------')\n", 101 | " print('done!')\n", 102 | "\n", 103 | "run_widget.on_click(on_run_click)\n", 104 | "\n", 105 | "display(url_widget)\n", 106 | "display(pat_widget)\n", 107 | "display(area_widget)\n", 108 | "display(resource_widget)\n", 109 | "display(run_widget)" 110 | ] 111 | }, 112 | { 113 | "cell_type": "code", 114 | "execution_count": null, 115 | "metadata": {}, 116 | "outputs": [], 117 | "source": [] 118 | } 119 | ], 120 | "metadata": { 121 | "kernelspec": { 122 | "display_name": "Python 3", 123 | "language": "python", 124 | "name": "python3" 125 | }, 126 | "language_info": { 127 | "codemirror_mode": { 128 | "name": "ipython", 129 | "version": 3 130 | }, 131 | "file_extension": ".py", 132 | "mimetype": "text/x-python", 133 | "name": "python", 134 | "nbconvert_exporter": "python", 135 | "pygments_lexer": "ipython3", 136 | "version": "3.6.3" 137 | } 138 | }, 139 | "nbformat": 4, 140 | "nbformat_minor": 2 141 | } 142 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. All rights reserved. 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python samples for Azure DevOps 2 | 3 | This repository contains Python samples that show how to integrate with Azure DevOps and Team Foundation Server (TFS) using the [Azure DevOps Python API](https://github.com/Microsoft/azure-devops-python-api). 4 | 5 | **As of January 2021, we're no longer actively maintaining this repo.** 6 | Feel free to continue using it for inspiration or examples. 7 | We won't be updating or adding any samples, though. 8 | 9 | ## Explore 10 | 11 | Samples are organized by "area" (service) and "resource" within the `samples` package. 12 | Each sample module shows various ways for interacting with Azure DevOps and TFS. 13 | Resources may have multiple samples, since there are often multiple ways to query for a given resource. 14 | 15 | ## Installation 16 | 17 | 1. Clone this repository and `cd` into it 18 | 19 | 2. Create a virtual environment (`python3 -m venv env && . env/bin/activate && pip install -r requirements.txt`) 20 | 21 | Now you can run `runner.py` with no arguments to see available options. 22 | 23 | ## Run the samples - command line 24 | 25 | > **VERY IMPORTANT**: some samples are destructive! It is recommended that you run these samples against a test organization. 26 | 27 | 1. Get a [personal access token](https://docs.microsoft.com/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops). 28 | 29 | 2. Store the PAT and organization URL you'll be running samples against (note: some samples are destructive, so use a test organization): 30 | * `python runner.py config url --set-to https://dev.azure.com/fabrikam` 31 | * `python runner.py config pat --set-to ABC123` 32 | * If you don't want your PAT persisted to a file, you can put it in an environment variable called `AZURE_DEVOPS_PAT` instead 33 | 34 | 3. Run `python runner.py run {area} {resource}` with the 2 required arguments: 35 | * `{area}`: API area (currently `core`, `git`, and `work_item_tracking`) to run the client samples for. Use `all` to include all areas. 36 | * `{resource}`: API resource to run the client samples for. Use `all` to include all resources. 37 | * You can optionally pass `--url {url}` to override your configured URL 38 | 39 | ### Examples 40 | 41 | #### Run all samples 42 | 43 | ``` 44 | python runner.py run all all 45 | ``` 46 | 47 | #### Run all work item tracking samples 48 | 49 | ``` 50 | python runner.py run work_item_tracking all 51 | ``` 52 | 53 | #### Run all Git pull request samples 54 | 55 | ``` 56 | python runner.py run git pullrequests 57 | ``` 58 | 59 | #### Run all Git samples against a different URL than the one configured; in this case, a TFS on-premises collection 60 | 61 | ``` 62 | python runner.py run git all --url https://mytfs:8080/tfs/testcollection 63 | ``` 64 | 65 | ### Save request and response data to a JSON file 66 | 67 | To persist the HTTP request/response as JSON for each client sample method that is run, set the `--output-path {value}` argument. For example: 68 | 69 | ``` 70 | python runner.py run all all --output-path ~/temp/http-output 71 | ``` 72 | 73 | This creates a folder for each area, a folder for each resource under the area folder, and a file for each client sample method that was run. The name of the JSON file is determined by the name of the client sample method. For example: 74 | 75 | ``` 76 | |-- temp 77 | |-- http-output 78 | |-- git 79 | |-- refs 80 | |-- get_refs.json 81 | |-- ... 82 | |-- repositories 83 | |-- get_repositories.json 84 | |-- ... 85 | ``` 86 | 87 | Note: certain HTTP headers like `Authorization` are removed for security/privacy purposes. 88 | 89 | ## See what samples are available 90 | 91 | You can run `runner.py list` to see what sample areas and resources are available. 92 | 93 | ## Run the samples - Jupyter notebook 94 | 95 | We also provide a Jupyter notebook for running the samples. 96 | You'll get a web browser where you can enter URL, authentication token, and choose which samples you wish to run. 97 | 98 | 1. Clone this repository and `cd` into it 99 | 100 | 2. Create a virtual environment (`python3 -m venv env && . env/bin/activate && pip install -r requirements.jupyter.txt`) 101 | 102 | 3. Get a personal access token. 103 | 104 | 4. Run `jupyter notebook`. In the resulting web browser, click **API Samples.ipynb**. 105 | 106 | 5. Click **Run** in the top cell. Scroll down and you'll see a form where you can enter your organization or TFS collection URL, PAT, and choose which samples to run. 107 | 108 | > **IMPORTANT**: some samples are destructive. It is recommended that you first run the samples against a test account. 109 | 110 | ## Contribute 111 | 112 | This repo is no longer maintained, and therefore is not accepting new contributions. 113 | 114 | ~~This project welcomes contributions and suggestions. 115 | Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. 116 | For details, visit https://cla.microsoft.com.~~ 117 | 118 | ~~When you submit a pull request, a CLA-bot will automatically determine whether you need to provide a CLA and decorate the PR appropriately (e.g., label, comment). 119 | Simply follow the instructions provided by the bot. 120 | You will only need to do this once across all repos using our CLA.~~ 121 | 122 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 123 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 124 | 125 | ~~See detailed instructions on how to [contribute a sample](./contribute.md).~~ 126 | -------------------------------------------------------------------------------- /contribute.md: -------------------------------------------------------------------------------- 1 | # Contribute to the samples 2 | 3 | **As of January 2021, this repo is no longer being actively maintained. 4 | It no longer accepts contributions.** 5 | This document remains to explain how the samples are laid out. 6 | 7 | ## Organization and style 8 | 9 | 1. Samples for an API area should live together under the `samples` package. Each module is an area. 10 | ``` 11 | |-- src 12 | |-- samples 13 | |-- git.py 14 | ``` 15 | 16 | 2. Within a module, create a method for each sample. Samples are not object-oriented. 17 | * The name of the method should represent what the sample is demonstrating, e.g. `get_{resource}`: 18 | ```python 19 | def get_repos(...): 20 | ``` 21 | * The method must accept a `context` parameter, which will contain information passed in from the sample runner infrastructure: 22 | ```python 23 | def get_repos(context): 24 | ``` 25 | * The method must decorated with `@resource('resource_name')` to be detected: 26 | ```python 27 | from samples import resource 28 | 29 | @resource('repositories') 30 | def get_repos(context): 31 | ``` 32 | * Results should be logged using the `utils.emit` function: 33 | ```python 34 | from samples import resource 35 | from utils import emit, find_any_project 36 | 37 | @resource('repositories') 38 | def get_repos(context): 39 | project = find_any_project(context) 40 | 41 | git_client = context.connection.clients.get_git_client() 42 | 43 | repos = git_client.get_repositories(project.id) 44 | for repo in repos: 45 | emit(repo) 46 | 47 | return repos 48 | ``` 49 | 50 | 51 | 3. Coding and style 52 | * Samples should show catching exceptions for APIs where exceptions are common 53 | * Use line breaks and empty lines to help delineate important sections or lines that need to stand out 54 | * Use the same "dummy" data across all samples so it's easier to correlate similar concepts 55 | * Be as Pythonic and PEP8-y as you can be without violating the above principles. `flake8` is your friend, and should run without anything triggering. (Non-standard default: 120-character lines are allowed.) 56 | 57 | 4. All samples **MUST** be runnable on their own without any input 58 | 59 | 5. All samples **SHOULD** clean up after themselves. 60 | Have a sample method create a resource (to demonstrate creation). 61 | Have a later sample method delete the previously created resource. 62 | In between the creation and deletion, you can show updating the resource (if applicable) 63 | 64 | # Tests 65 | 66 | You can run `pip install -r requirements.dev.txt && pytest` to run tests. 67 | They should all pass. 68 | -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | # if you want to run tests, you'll need pytest 2 | pytest==3.5.0 3 | -r requirements.txt 4 | -------------------------------------------------------------------------------- /requirements.jupyter.txt: -------------------------------------------------------------------------------- 1 | # if you want to load the Jupyter notebook, use this requirements file 2 | ipywidgets==7.0.5 3 | jupyter==1.0.0 4 | -r requirements.txt 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # if you just want the command-line samples experience, use this requirements file 2 | azure-devops==5.0.0b5 3 | -------------------------------------------------------------------------------- /src/.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = __pycache__ 3 | max-line-length = 120 4 | -------------------------------------------------------------------------------- /src/config.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import pathlib 4 | import sys 5 | 6 | from utils import emit 7 | 8 | 9 | DEFAULT_CONFIG_FILE_NAME = "azure-devops-runner-config.json" 10 | OLD_DEFAULT_CONFIG_FILE_NAME = "vsts-runner-config.json" 11 | CONFIG_KEYS = [ 12 | 'url', 13 | 'pat', 14 | ] 15 | 16 | 17 | class Config(): 18 | def __init__(self, filename=None): 19 | if not filename: 20 | runner_path = (pathlib.Path(os.getcwd()) / pathlib.Path(sys.argv[0])).resolve() 21 | filename = runner_path.parents[0] / pathlib.Path(DEFAULT_CONFIG_FILE_NAME) 22 | 23 | self._filename = filename 24 | 25 | try: 26 | with open(filename) as config_fp: 27 | self._config = json.load(config_fp) 28 | except FileNotFoundError: 29 | emit("warning: no config file found.") 30 | emit("The default filename has changed. You may need to rename") 31 | emit(OLD_DEFAULT_CONFIG_FILE_NAME) 32 | emit("to") 33 | emit(DEFAULT_CONFIG_FILE_NAME) 34 | self._config = {} 35 | except json.JSONDecodeError: 36 | emit("possible bug: config file exists but isn't parseable") 37 | self._config = {} 38 | 39 | def __getitem__(self, name): 40 | self._check_if_name_valid(name) 41 | return self._config.get(name, None) 42 | 43 | def __setitem__(self, name, value): 44 | self._check_if_name_valid(name) 45 | self._config[name] = value 46 | 47 | def __delitem__(self, name): 48 | self._check_if_name_valid(name) 49 | self._config.pop(name, None) 50 | 51 | def __len__(self): 52 | return len(CONFIG_KEYS) 53 | 54 | def __iter__(self): 55 | for key in CONFIG_KEYS: 56 | yield key 57 | 58 | def save(self): 59 | with open(self._filename, 'w') as config_fp: 60 | json.dump(self._config, config_fp, sort_keys=True, indent=4) 61 | 62 | def _check_if_name_valid(self, name): 63 | if name not in CONFIG_KEYS: 64 | raise KeyError("{0} is not a valid config key".format(name)) 65 | -------------------------------------------------------------------------------- /src/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Exception classes used in the sample runner. 3 | """ 4 | 5 | 6 | class AccountStateError(Exception): 7 | "For when an account doesn't have the right preconditions to support a sample." 8 | pass 9 | -------------------------------------------------------------------------------- /src/hacks.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import warnings 3 | 4 | from http_logging import requests_hook 5 | 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def add_request_hook(client): 11 | "This is a bit of a hack until we have a supported way to install the hook." 12 | warnings.warn("hacking in the request hook", DeprecationWarning) 13 | 14 | if requests_hook in client.config.hooks: 15 | logger.debug("hook already installed; skipped") 16 | return 17 | 18 | logger.debug("installing hook") 19 | client.config.hooks.append(requests_hook) 20 | -------------------------------------------------------------------------------- /src/http_logging.py: -------------------------------------------------------------------------------- 1 | """ 2 | HTTP logger hook (for dumping the request/response cycle). 3 | """ 4 | from contextlib import contextmanager 5 | import json 6 | 7 | 8 | ### 9 | # Logging state management 10 | ### 11 | 12 | _enabled_stack = [False] 13 | target = None 14 | 15 | 16 | def push_state(enabled): 17 | _enabled_stack.append(enabled) 18 | 19 | 20 | def pop_state(): 21 | if len(_enabled_stack) == 1: 22 | # never pop the last state 23 | return bool(_enabled_stack[0]) 24 | elif len(_enabled_stack) == 0: 25 | # something's gone terribly wrong 26 | raise RuntimeError("_enabled_stack should never be empty") 27 | else: 28 | return bool(_enabled_stack.pop()) 29 | 30 | 31 | def logging_enabled(): 32 | return bool(_enabled_stack[-1]) 33 | 34 | 35 | @contextmanager 36 | def temporarily_disabled(): 37 | """Temporarily disable logging if it's enabled. 38 | 39 | with http_logging.temporarily_disabled(): 40 | my_client.do_thing() 41 | 42 | """ 43 | push_state(False) 44 | yield 45 | pop_state() 46 | 47 | 48 | ### 49 | # Actual HTTP logging and hook 50 | ### 51 | 52 | def _trim_headers(headers): 53 | trimmable_headers = [ 54 | "X-VSS-PerfData", 55 | "X-TFS-Session", 56 | "X-VSS-E2EID", 57 | "X-VSS-Agent", 58 | "Authorization", 59 | "X-TFS-ProcessId", 60 | "X-VSS-UserData", 61 | "ActivityId", 62 | "P3P", 63 | "X-Powered-By", 64 | "Cookie", 65 | "X-TFS-FedAuthRedirect", 66 | "Strict-Transport-Security", 67 | "X-Frame-Options", 68 | "X-Content-Type-Options", 69 | "X-AspNet-Version", 70 | "Server", 71 | "Pragma", 72 | "vary", 73 | "X-MSEdge-Ref", 74 | "Cache-Control", 75 | "Date", 76 | "User-Agent", 77 | "Accept-Language", 78 | ] 79 | 80 | cleaned_headers = headers.copy() 81 | 82 | for trim_header in trimmable_headers: 83 | try: 84 | del cleaned_headers[trim_header] 85 | except KeyError: 86 | pass 87 | 88 | return dict(cleaned_headers) 89 | 90 | 91 | def log_request(response, file): 92 | try: 93 | content = response.json() 94 | except ValueError: 95 | content = response.text 96 | 97 | data = { 98 | 'request': { 99 | 'url': response.request.url, 100 | 'headers': _trim_headers(response.request.headers), 101 | 'body': str(response.request.body), 102 | 'method': response.request.method, 103 | }, 104 | 'response': { 105 | 'headers': _trim_headers(response.headers), 106 | 'body': content, 107 | 'status': response.status_code, 108 | 'url': response.url, 109 | }, 110 | } 111 | 112 | json.dump(data, file, indent=4) 113 | 114 | 115 | def requests_hook(response, *args, **kwargs): 116 | global target 117 | 118 | if logging_enabled() and target is not None: 119 | log_request(response, target) 120 | -------------------------------------------------------------------------------- /src/runner.py: -------------------------------------------------------------------------------- 1 | """ 2 | Azure DevOps Python API sample runner. 3 | """ 4 | import argparse 5 | import logging 6 | import os 7 | import pathlib 8 | import sys 9 | from types import SimpleNamespace 10 | 11 | # logging.basicConfig(level=logging.INFO) 12 | 13 | from azure.devops.credentials import BasicAuthentication 14 | from azure.devops.connection import Connection 15 | 16 | from config import Config 17 | import http_logging 18 | import hacks 19 | import runner_lib 20 | from utils import emit 21 | 22 | __VERSION__ = "1.0.0" 23 | 24 | logger = logging.getLogger(__name__) 25 | 26 | 27 | def main(url, area, resource, auth_token, output_path=None): 28 | 29 | context = SimpleNamespace() 30 | context.runner_cache = SimpleNamespace() 31 | 32 | # setup the connection 33 | context.connection = Connection( 34 | base_url=url, 35 | creds=BasicAuthentication('PAT', auth_token), 36 | user_agent='azure-devops-python-samples/' + __VERSION__) 37 | 38 | # if the user asked for logging: 39 | # - add a hook for logging the http request 40 | # - create the root directory 41 | if output_path: 42 | # monkey-patch the get_client method to attach our hook 43 | _get_client = context.connection.get_client 44 | 45 | def get_client_with_hook(*args, **kwargs): 46 | logger.debug("get_client_with_hook") 47 | client = _get_client(*args, **kwargs) 48 | hacks.add_request_hook(client) 49 | return client 50 | context.connection.get_client = get_client_with_hook 51 | 52 | root_log_dir = pathlib.Path(output_path) 53 | if not root_log_dir.exists(): 54 | root_log_dir.mkdir(parents=True, exist_ok=True) 55 | http_logging.push_state(True) 56 | else: 57 | root_log_dir = None 58 | 59 | # runner_lib.discovered_samples will contain a key for each area loaded, 60 | # and each key will have the resources and sample functions discovered 61 | if area == 'all': 62 | areas = runner_lib.discovered_samples.keys() 63 | else: 64 | if area not in runner_lib.discovered_samples.keys(): 65 | raise ValueError("area '%s' doesn't exist" % (area,)) 66 | areas = [area] 67 | 68 | for area in areas: 69 | 70 | area_logging_path = runner_lib.enter_area(area, root_log_dir) 71 | 72 | for area_resource, functions in runner_lib.discovered_samples[area].items(): 73 | if area_resource != resource and resource != 'all': 74 | logger.debug("skipping resource %s", area_resource) 75 | continue 76 | 77 | resource_logging_path = runner_lib.enter_resource(area_resource, area_logging_path) 78 | 79 | for run_sample in functions: 80 | runner_lib.before_run_sample(run_sample.__name__, resource_logging_path) 81 | run_sample(context) 82 | runner_lib.after_run_sample(resource_logging_path) 83 | 84 | 85 | def list_cmd(args, config): 86 | template = " <{0}>: {1}" 87 | print() 88 | print("Available s and resources") 89 | print(template.format("all", "all")) 90 | for area in runner_lib.discovered_samples.keys(): 91 | resources = ", ".join(runner_lib.discovered_samples[area].keys()) 92 | print(template.format(area, resources)) 93 | print() 94 | print("For any area, you can always pass 'all' to run all resource samples") 95 | 96 | 97 | def run_cmd(args, config): 98 | try: 99 | auth_token = os.environ['AZURE_DEVOPS_PAT'] 100 | except KeyError: 101 | if config['pat']: 102 | emit("Using auth token from config file") 103 | auth_token = config['pat'] 104 | else: 105 | emit('You must first set the AZURE_DEVOPS_PAT environment variable or the `pat` config setting') 106 | sys.exit(1) 107 | 108 | if not args.url: 109 | if config['url']: 110 | args.url = config['url'] 111 | emit('Using configured URL {0}'.format(args.url)) 112 | else: 113 | emit('No URL configured - pass it on the command line') 114 | sys.exit(1) 115 | 116 | args_dict = vars(args) 117 | 118 | main(**args_dict, auth_token=auth_token) 119 | 120 | 121 | def config_cmd(args, config): 122 | template = " {0}: {1}" 123 | 124 | if args.name == 'all': 125 | emit("Configured settings") 126 | for name in config: 127 | emit(template.format(name, config[name])) 128 | return 129 | 130 | args.name = args.name.lower() 131 | 132 | if args.set_to: 133 | if args.name in config: 134 | config[args.name] = args.set_to 135 | emit("Setting new value for {0}".format(args.name)) 136 | emit(template.format(args.name, config[args.name])) 137 | config.save() 138 | else: 139 | emit("There's no setting called {0}".format(args.name)) 140 | 141 | elif args.delete: 142 | if args.name in config: 143 | emit("Deleting {0}; old value was".format(args.name)) 144 | emit(template.format(args.name, config[args.name])) 145 | del config[args.name] 146 | config.save() 147 | else: 148 | emit("There's no setting called {0}".format(args.name)) 149 | 150 | else: 151 | if args.name in config: 152 | emit(template.format(args.name, config[args.name])) 153 | else: 154 | emit("There's no setting called {0}".format(args.name)) 155 | 156 | 157 | if __name__ == '__main__': 158 | 159 | # main parser 160 | parser = argparse.ArgumentParser(description='Azure DevOps Python API samples') 161 | subparsers = parser.add_subparsers() 162 | 163 | # "list" 164 | discover_parser = subparsers.add_parser('list') 165 | discover_parser.set_defaults(dispatch=list_cmd) 166 | 167 | # "run" 168 | run_parser = subparsers.add_parser('run') 169 | run_parser.add_argument('area', help='Product area to run samples for, or `all`') 170 | run_parser.add_argument('resource', help='Resource to run samples for, or `all`') 171 | run_parser.add_argument('-u', '--url', help='Base URL of your Azure DevOps or TFS instance') 172 | run_parser.add_argument('-o', '--output-path', help='Root folder to save request/response data', 173 | metavar='DIR') 174 | run_parser.set_defaults(dispatch=run_cmd) 175 | 176 | # "config" 177 | config_parser = subparsers.add_parser('config') 178 | config_parser.add_argument('name', help='Name of setting to get or set, or `all` to list all of them') 179 | config_parser.add_argument('--set-to', help='New value for setting') 180 | config_parser.add_argument('--delete', help='New value for setting', action='store_true') 181 | config_parser.set_defaults(dispatch=config_cmd) 182 | 183 | args = parser.parse_args() 184 | if 'dispatch' in args: 185 | cmd = args.dispatch 186 | del args.dispatch 187 | cmd(args, Config()) 188 | else: 189 | parser.print_usage() 190 | -------------------------------------------------------------------------------- /src/runner_lib.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helper methods moved out of the main runner file. 3 | """ 4 | import importlib 5 | import logging 6 | import pathlib 7 | import pkgutil 8 | 9 | import http_logging 10 | from utils import emit 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | ### 15 | # Sample discovery 16 | ### 17 | 18 | SAMPLES_MODULE_NAME = 'samples' 19 | 20 | logger.debug("loading samples module") 21 | _samples_module = importlib.import_module(SAMPLES_MODULE_NAME) 22 | 23 | logger.debug('loading all modules in `%s`', SAMPLES_MODULE_NAME) 24 | for _, name, _ in pkgutil.iter_modules(_samples_module.__path__): 25 | importlib.import_module('%s.%s' % (SAMPLES_MODULE_NAME, name)) 26 | 27 | # trim the sample module name off the area names 28 | discovered_samples = { 29 | area[len(SAMPLES_MODULE_NAME)+1:]: module for area, module in _samples_module.discovered_samples.items() 30 | } 31 | 32 | 33 | ### 34 | # Logging helpers and so on 35 | ### 36 | 37 | def enter_area(area, http_logging_path): 38 | emit("== %s ==", area) 39 | 40 | if http_logging_path is not None: 41 | area_log_dir = pathlib.Path(http_logging_path / area) 42 | area_log_dir.mkdir(parents=True, exist_ok=True) 43 | return area_log_dir 44 | 45 | return None 46 | 47 | 48 | def enter_resource(resource, http_logging_path): 49 | emit("-- %s --", resource) 50 | 51 | if http_logging_path is not None: 52 | resource_log_dir = pathlib.Path(http_logging_path / resource) 53 | resource_log_dir.mkdir(parents=True, exist_ok=True) 54 | return resource_log_dir 55 | 56 | return None 57 | 58 | 59 | def before_run_sample(func_name, http_logging_path): 60 | if http_logging_path is not None: 61 | example_log_file = pathlib.Path(http_logging_path / (func_name + '.json')) 62 | http_logging.target = example_log_file.open('w') 63 | 64 | 65 | def after_run_sample(http_logging_path): 66 | if http_logging_path is not None: 67 | http_logging.target.close() 68 | http_logging.target = None 69 | -------------------------------------------------------------------------------- /src/samples/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from utils import emit 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | discovered_samples = {} 8 | 9 | 10 | def resource(decorated_resource): 11 | def decorate(sample_func): 12 | def run(*args, **kwargs): 13 | emit("Running `{0}.{1}`".format(sample_func.__module__, sample_func.__name__)) 14 | sample_func(*args, **kwargs) 15 | 16 | run.__name__ = sample_func.__name__ 17 | 18 | if sample_func.__module__ not in discovered_samples: 19 | logger.debug("Discovered area `%s`", sample_func.__module__) 20 | discovered_samples[sample_func.__module__] = {} 21 | 22 | area_samples = discovered_samples[sample_func.__module__] 23 | if decorated_resource not in area_samples: 24 | logger.debug("Discovered resource `%s`", decorated_resource) 25 | area_samples[decorated_resource] = [] 26 | 27 | logger.debug("Discovered function `%s`", sample_func.__name__) 28 | area_samples[decorated_resource].append(run) 29 | 30 | return run 31 | return decorate 32 | -------------------------------------------------------------------------------- /src/samples/build.py: -------------------------------------------------------------------------------- 1 | """ 2 | Build samples. 3 | """ 4 | import logging 5 | 6 | from samples import resource 7 | from utils import emit, find_any_project, find_any_build_definition 8 | 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | @resource('definition') 14 | def get_definitions(context): 15 | project = find_any_project(context) 16 | emit(project.name) 17 | build_client = context.connection.clients.get_build_client() 18 | 19 | definitions = build_client.get_definitions(project.id) 20 | 21 | for definition in definitions: 22 | emit(str(definition.id) + ": " + definition.name) 23 | 24 | return definitions 25 | 26 | 27 | @resource('build') 28 | def queue_build(context): 29 | definition = find_any_build_definition(context) 30 | 31 | build_client = context.connection.clients.get_build_client() 32 | 33 | build = { 34 | 'definition': { 35 | 'id': definition.id 36 | } 37 | } 38 | 39 | response = build_client.queue_build(build=build, project=definition.project.id) 40 | 41 | emit(str(response.id) + ": " + response.url) 42 | 43 | return response 44 | -------------------------------------------------------------------------------- /src/samples/core.py: -------------------------------------------------------------------------------- 1 | """ 2 | Core samples 3 | """ 4 | import logging 5 | 6 | from samples import resource 7 | from utils import emit 8 | 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | @resource('projects') 14 | def get_projects(context): 15 | core_client = context.connection.clients.get_core_client() 16 | 17 | projects = core_client.get_projects() 18 | 19 | for project in projects: 20 | emit(project.id + ": " + project.name) 21 | 22 | return projects 23 | -------------------------------------------------------------------------------- /src/samples/git.py: -------------------------------------------------------------------------------- 1 | """ 2 | Git samples. 3 | """ 4 | import logging 5 | 6 | from samples import resource 7 | from utils import emit, find_any_project, find_any_repo 8 | 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | @resource('repositories') 14 | def get_repos(context): 15 | project = find_any_project(context) 16 | 17 | git_client = context.connection.clients.get_git_client() 18 | 19 | repos = git_client.get_repositories(project.id) 20 | 21 | for repo in repos: 22 | emit(repo.id + ": " + repo.name) 23 | 24 | return repos 25 | 26 | 27 | @resource('refs') 28 | def get_refs(context): 29 | repo = find_any_repo(context) 30 | 31 | git_client = context.connection.clients.get_git_client() 32 | 33 | refs = git_client.get_refs(repo.id, repo.project.id) 34 | 35 | for ref in refs: 36 | emit(ref.name + ": " + ref.object_id) 37 | 38 | return refs 39 | -------------------------------------------------------------------------------- /src/samples/test.py: -------------------------------------------------------------------------------- 1 | """ 2 | TEST samples 3 | """ 4 | import datetime 5 | import logging 6 | 7 | from samples import resource 8 | from utils import emit 9 | 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def get_project_names(context): 15 | core_client = context.connection.clients.get_core_client() 16 | return (project.name for project in core_client.get_projects()) 17 | 18 | 19 | @resource("test_plans") 20 | def get_plans(context): 21 | test_client = context.connection.clients.get_test_client() 22 | for project in get_project_names(context): 23 | try: 24 | for plan in test_client.get_plans(project): 25 | emit("Test Plan {}: {} ({})".format(plan.id, plan.name, plan.area.name)) 26 | except Exception as e: 27 | emit("Project '{}' raised error: {}".format(project, e)) 28 | 29 | 30 | @resource("test_suites") 31 | def get_test_suites_for_plan(context): 32 | test_client = context.connection.clients.get_test_client() 33 | for project in get_project_names(context): 34 | try: 35 | for plan in test_client.get_plans(project): 36 | for suite in test_client.get_test_suites_for_plan(project, plan.id): 37 | emit( 38 | "Test Suite {}: {} ({}.{})".format( 39 | suite.id, suite.name, plan.id, plan.name 40 | ) 41 | ) 42 | except Exception as e: 43 | emit("Project '{}' raised error: {}".format(project, e)) 44 | 45 | 46 | @resource("test_runs") 47 | def get_test_runs(context): 48 | test_client = context.connection.clients.get_test_client() 49 | for project in get_project_names(context): 50 | try: 51 | for run in test_client.get_test_runs(project, top=16): 52 | emit( 53 | "Test Run {}: {} => {} ({})".format( 54 | run.id, run.name, run.state, project 55 | ) 56 | ) 57 | except Exception as e: 58 | emit("Project '{}' raised error: {}".format(project, e)) 59 | 60 | 61 | @resource("test_results") 62 | def get_test_results(context): 63 | test_client = context.connection.clients.get_test_client() 64 | for project in get_project_names(context): 65 | try: 66 | for run in test_client.get_test_runs(project, top=10): 67 | # Limiting Test Results is not something one shall do! 68 | for res in test_client.get_test_results(project, run.id, top=3): 69 | tc = res.test_case 70 | tester = res.run_by.display_name 71 | emit( 72 | "Test Result {}: {} => {} by {} ({})".format( 73 | run.id, tc.name, res.outcome, tester, project 74 | ) 75 | ) 76 | except Exception as e: 77 | emit("Project '{}' raised error: {}".format(project, e)) 78 | -------------------------------------------------------------------------------- /src/samples/work_item_tracking.py: -------------------------------------------------------------------------------- 1 | """ 2 | WIT samples 3 | """ 4 | import datetime 5 | import logging 6 | 7 | from samples import resource 8 | from utils import emit 9 | 10 | from azure.devops.v5_1.work_item_tracking.models import Wiql 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def print_work_item(work_item): 16 | emit( 17 | "{0} {1}: {2}".format( 18 | work_item.fields["System.WorkItemType"], 19 | work_item.id, 20 | work_item.fields["System.Title"], 21 | ) 22 | ) 23 | 24 | 25 | @resource("work_items") 26 | def get_work_items(context): 27 | wit_client = context.connection.clients.get_work_item_tracking_client() 28 | 29 | desired_ids = range(1, 51) 30 | work_items = wit_client.get_work_items(ids=desired_ids, error_policy="omit") 31 | 32 | for id_, work_item in zip(desired_ids, work_items): 33 | if work_item: 34 | print_work_item(work_item) 35 | else: 36 | emit("(work item {0} omitted by server)".format(id_)) 37 | 38 | return work_items 39 | 40 | 41 | @resource("work_items") 42 | def get_work_items_as_of(context): 43 | wit_client = context.connection.clients.get_work_item_tracking_client() 44 | 45 | desired_ids = range(1, 51) 46 | as_of_date = datetime.datetime.now() + datetime.timedelta(days=-7) 47 | work_items = wit_client.get_work_items( 48 | ids=desired_ids, as_of=as_of_date, error_policy="omit" 49 | ) 50 | 51 | for id_, work_item in zip(desired_ids, work_items): 52 | if work_item: 53 | print_work_item(work_item) 54 | else: 55 | emit("(work item {0} omitted by server)".format(id_)) 56 | 57 | return work_items 58 | 59 | 60 | @resource("wiql_query") 61 | def wiql_query(context): 62 | wit_client = context.connection.clients.get_work_item_tracking_client() 63 | wiql = Wiql( 64 | query=""" 65 | select [System.Id], 66 | [System.WorkItemType], 67 | [System.Title], 68 | [System.State], 69 | [System.AreaPath], 70 | [System.IterationPath], 71 | [System.Tags] 72 | from WorkItems 73 | where [System.WorkItemType] = 'Test Case' 74 | order by [System.ChangedDate] desc""" 75 | ) 76 | # We limit number of results to 30 on purpose 77 | wiql_results = wit_client.query_by_wiql(wiql, top=30).work_items 78 | emit("Results: {0}".format(len(wiql_results))) 79 | if wiql_results: 80 | # WIQL query gives a WorkItemReference with ID only 81 | # => we get the corresponding WorkItem from id 82 | work_items = ( 83 | wit_client.get_work_item(int(res.id)) for res in wiql_results 84 | ) 85 | for work_item in work_items: 86 | print_work_item(work_item) 87 | return work_items 88 | else: 89 | return [] 90 | -------------------------------------------------------------------------------- /src/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/azure-devops-python-samples/552544e9cde70269e37784aff2e62dd97420b862/src/tests/__init__.py -------------------------------------------------------------------------------- /src/tests/test_http_logging.py: -------------------------------------------------------------------------------- 1 | from argparse import Namespace 2 | import io 3 | 4 | import http_logging 5 | 6 | 7 | def test_logging(): 8 | f = io.StringIO() 9 | 10 | response = Namespace( 11 | request = Namespace( 12 | url = 'https://example.com/fake', 13 | headers = { 14 | 'X-Request-Test-Header': 'keep', 15 | 'Authorization': 'filter-out', 16 | }, 17 | body = '', 18 | method = 'GET' 19 | ), 20 | headers = { 21 | 'X-Response-Test-Header': 'keep', 22 | 'Cookie': 'filter-out', 23 | }, 24 | status_code = 200, 25 | url = 'https://example.com/fake/response', 26 | json = lambda: "" 27 | ) 28 | 29 | http_logging.log_request(response, f) 30 | 31 | f.seek(0) 32 | contents = f.read() 33 | 34 | # ensure we don't strip arbitrary headers 35 | assert 'X-Request-Test-Header' in contents 36 | assert 'X-Response-Test-Header' in contents 37 | 38 | # ensure we strip sensitive headers 39 | assert 'Authorization' not in contents 40 | assert 'Cookie' not in contents 41 | -------------------------------------------------------------------------------- /src/tests/test_runner.py: -------------------------------------------------------------------------------- 1 | import runner 2 | import runner_lib 3 | 4 | 5 | def test_list_cmd(capsys): 6 | # this is a pretty silly test, but it at least ensures we imported `runner` 7 | assert runner.list_cmd(None, None) == None 8 | captured = capsys.readouterr() 9 | assert ": all" in captured.out 10 | 11 | 12 | def test_sample_discovery(): 13 | # make sure some of the samples are discovered -- doesn't need to be everything 14 | assert 'git' in runner_lib.discovered_samples.keys() 15 | assert 'core' in runner_lib.discovered_samples.keys() 16 | -------------------------------------------------------------------------------- /src/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utility methods likely to be useful for anyone building samples. 3 | """ 4 | import logging 5 | 6 | from exceptions import AccountStateError 7 | import http_logging 8 | 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def emit(msg, *args): 14 | print(msg % args) 15 | 16 | 17 | def find_any_project(context): 18 | logger.debug('finding any project') 19 | 20 | # if we already contains a looked-up project, return it 21 | if hasattr(context.runner_cache, 'project'): 22 | logger.debug('using cached project %s', context.runner_cache.project.name) 23 | return context.runner_cache.project 24 | 25 | with http_logging.temporarily_disabled(): 26 | core_client = context.connection.clients.get_core_client() 27 | projects = core_client.get_projects() 28 | 29 | try: 30 | context.runner_cache.project = projects[0] 31 | logger.debug('found %s', context.runner_cache.project.name) 32 | return context.runner_cache.project 33 | except IndexError: 34 | raise AccountStateError('Your account doesn''t appear to have any projects available.') 35 | 36 | 37 | def find_any_repo(context): 38 | logger.debug('finding any repo') 39 | 40 | # if a repo is cached, use it 41 | if hasattr(context.runner_cache, 'repo'): 42 | logger.debug('using cached repo %s', context.runner_cache.repo.name) 43 | return context.runner_cache.repo 44 | 45 | with http_logging.temporarily_disabled(): 46 | project = find_any_project(context) 47 | git_client = context.connection.clients.get_git_client() 48 | repos = git_client.get_repositories(project.id) 49 | 50 | try: 51 | context.runner_cache.repo = repos[0] 52 | return context.runner_cache.repo 53 | except IndexError: 54 | raise AccountStateError('Project "%s" doesn''t appear to have any repos.' % (project.name,)) 55 | 56 | 57 | def find_any_build_definition(context): 58 | logger.debug('finding any build definition') 59 | 60 | # if a repo is cached, use it 61 | if hasattr(context.runner_cache, 'build_definition'): 62 | logger.debug('using cached definition %s', context.runner_cache.build_definition.name) 63 | return context.runner_cache.build_definition 64 | 65 | with http_logging.temporarily_disabled(): 66 | project = find_any_project(context) 67 | build_client = context.connection.clients.get_build_client() 68 | definitions = build_client.get_definitions(project.id) 69 | 70 | try: 71 | context.runner_cache.build_definition = definitions[0] 72 | return context.runner_cache.build_definition 73 | except IndexError: 74 | raise AccountStateError('Project "%s" doesn''t appear to have any build definitions.' % (project.name,)) 75 | --------------------------------------------------------------------------------