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