diff --git a/README.md b/README.md index ecbfada..8e237db 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,10 @@ TFS Python Library (TFS API Python client) - [Quickstart](#quickstart) - [Installation](#installation) - [Create connection](#create-connection) + - [Timeout connection](#timeout-connection) - [Workitem](#workitem) - - [Run Queries](#run-queries) + - [Run Saved Queries](#run-saved-queries) + - [Run WIQL](#run-wiql) - [Changesets](#changesets) - [Project and Team](#project--team) - [Guide](#guide) @@ -53,6 +55,14 @@ client = TFSAPI("https://tfs.tfs.ru/tfs/", project="Development/ProjectName", us workitem = client.get_workitem(100) # Test connection with Workitem id ``` +## Timeout connection +You can set CONNECT and READ timeouts ([read more](http://docs.python-requests.org/en/master/user/advanced/#timeouts)) +```python +from tfs import TFSAPI +client = TFSAPI("https://tfs.tfs.ru/tfs/", user=user, password=password, connect_timeout=30, read_timeout=None) + +``` + ## Workitem ```python # For single Workitem @@ -87,7 +97,7 @@ if childs: # Child is empty list if Workitem hasn't Child link print("Workitem with id={} have Childs={}".format(workitem.id, ",".join([x.id for x in childs]))) ``` -## Run Queries +## Run Saved Queries You can run Saved Queries and get Workitems ```python # Set path to ProjectName in project parameter @@ -105,6 +115,38 @@ print(quiery.column_names) workitems = quiery.workitems ``` +## Run WIQL +You can run [Work Item Query Language](https://msdn.microsoft.com/en-us/library/bb130198(v=vs.90).aspx) +```python +# Set path to ProjectName in project parameter +client = TFSAPI("https://tfs.tfs.ru/tfs/", project="Development/ProjectName", user=user, password=password) + +# Run custom query +### NOTE: Fields in SELECT really ignored, wiql return Work Items with all fields +query = """SELECT + [System.Id], + [System.WorkItemType], + [System.Title], + [System.ChangedDate] +FROM workitems +WHERE + [System.WorkItemType] = 'Bug' +ORDER BY [System.ChangedDate]""" + +wiql = client.run_wiql(query) + +# Get founded Work Item ids +ids = wiql.workitem_ids +print("Found WI with ids={}".format(",".join(ids))) + +# Get RAW query data - python dict +raw = wiql.result + +# Get all found workitems +workitems = wiql.workitems +print(workitems[0]['Title']) +``` + ## Changesets ```python # Get changesets from 1000 to 1002 diff --git a/setup.py b/setup.py index d83d2c9..6a2a112 100644 --- a/setup.py +++ b/setup.py @@ -90,10 +90,8 @@ package_data={ '': [ - './tfs/*' - - 'LICENSE', - 'README.md', + '../LICENSE', + '../README.md', ], }, diff --git a/tests/conftest.py b/tests/conftest.py index 09ae5e7..bfb8080 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,12 +27,10 @@ def request_callback_get(request, uri, headers): @pytest.fixture(autouse=True) def tfs_server_mock(): - httpretty.register_uri(httpretty.GET, re.compile(r"http://tfs.tfs.ru(.*)"), - body=request_callback_get, - content_type="application/json") - httpretty.register_uri(httpretty.PATCH, re.compile(r"http://tfs.tfs.ru(.*)"), - body=request_callback_get, - content_type="application/json") + for method in (httpretty.GET, httpretty.POST, httpretty.PATCH): + httpretty.register_uri(method, re.compile(r"http://tfs.tfs.ru(.*)"), + body=request_callback_get, + content_type="application/json") @pytest.fixture() diff --git a/tests/resources/tfs/Development/_apis/wit/wiql/response.json b/tests/resources/tfs/Development/_apis/wit/wiql/response.json new file mode 100644 index 0000000..60af5ee --- /dev/null +++ b/tests/resources/tfs/Development/_apis/wit/wiql/response.json @@ -0,0 +1,50 @@ +{ + "queryResultType": "workItem", + "columns": [ + { + "name": "ID", + "url": "https:\/\/tfs.tfs.ru\/tfs\/Development\/_apis\/wit\/fields\/System.Id", + "referenceName": "System.Id" + }, + { + "name": "Severity", + "url": "https:\/\/tfs.tfs.ru\/tfs\/Development\/_apis\/wit\/fields\/Microsoft.VSTS.Common.Severity", + "referenceName": "Microsoft.VSTS.Common.Severity" + }, + { + "name": "Target Version", + "url": "https:\/\/tfs.tfs.ru\/tfs\/Development\/_apis\/wit\/fields\/TargetVersion", + "referenceName": "TargetVersion" + } + ], + "sortColumns": [ + { + "field": { + "name": "Target Version", + "url": "https:\/\/tfs.tfs.ru\/tfs\/Development\/_apis\/wit\/fields\/TargetVersion", + "referenceName": "TargetVersion" + }, + "descending": false + }, + { + "field": { + "name": "Severity", + "url": "https:\/\/tfs.tfs.ru\/tfs\/Development\/_apis\/wit\/fields\/Microsoft.VSTS.Common.Severity", + "referenceName": "Microsoft.VSTS.Common.Severity" + }, + "descending": false + } + ], + "workItems": [ + { + "url": "https:\/\/tfs.tfs.ru\/tfs\/Development\/_apis\/wit\/workItems\/100", + "id": 100 + }, + { + "url": "https:\/\/tfs.tfs.ru\/tfs\/Development\/_apis\/wit\/workItems\/101", + "id": 101 + } + ], + "asOf": "2017-07-24T06:59:38.74Z", + "queryType": "flat" +} \ No newline at end of file diff --git a/tests/test_connection.py b/tests/test_connection.py index a860238..26fe1b5 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -35,6 +35,14 @@ def test_get_changesets(self, tfsapi): assert len(changesets) == 5 assert changesets[0].id == 10 + @pytest.mark.httpretty + def test_get_wiql(self, tfsapi): + wiql_query = "SELECT *" + wiql = tfsapi.run_wiql(wiql_query) + + assert isinstance(wiql, Wiql) + assert wiql.workitem_ids == [100, 101] + @pytest.mark.httpretty def test_get_projects(self, tfsapi): projects = tfsapi.get_projects() @@ -57,18 +65,19 @@ def test_get_teams(self, tfsapi): assert isinstance(team, TFSObject) assert team['name'] == 'ProjectName' - class TestHTTPClient: - def test__get_collection(self): - collection, project = TFSHTTPClient.get_collection_and_project('Development') - assert collection == 'Development' - assert project is None - - def test__get_collection_and_project(self): - collection, project = TFSHTTPClient.get_collection_and_project('Development/Project') - assert collection == 'Development' - assert project == 'Project' - - def test__get_collection_and_project_and_team(self): - collection, project = TFSHTTPClient.get_collection_and_project('Development/Project/Team') - assert collection == 'Development' - assert project == 'Project' + +class TestHTTPClient: + def test__get_collection(self): + collection, project = TFSHTTPClient.get_collection_and_project('Development') + assert collection == 'Development' + assert project is None + + def test__get_collection_and_project(self): + collection, project = TFSHTTPClient.get_collection_and_project('Development/Project') + assert collection == 'Development' + assert project == 'Project' + + def test__get_collection_and_project_and_team(self): + collection, project = TFSHTTPClient.get_collection_and_project('Development/Project/Team') + assert collection == 'Development' + assert project == 'Project' diff --git a/tests/test_tfs.py b/tests/test_resources.py similarity index 67% rename from tests/test_tfs.py rename to tests/test_resources.py index 572d948..e96337a 100644 --- a/tests/test_tfs.py +++ b/tests/test_resources.py @@ -163,6 +163,10 @@ def test_changeset_id(self, changeset): def test_changeset_fields(self, changeset): assert changeset['comment'] == "My Comment" + def test_changeset_fields_get(self, changeset): + assert changeset.get('comment') == "My Comment" + + @pytest.mark.httpretty def test_get_changesets_workitem(self, tfsapi): changesets = tfsapi.get_changesets(from_=10, to_=14) @@ -223,3 +227,130 @@ def test_tfsquery_column_names(self, tfsquery): assert len(tfsquery.workitems) == 2 assert tfsquery.workitems[0].id == 100 assert tfsquery.workitems[1].id == 101 + + +class TestWiql(object): + @pytest.fixture() + def wiql(self, tfsapi): + data_str = r"""{ + "queryResultType": "workItem", + "columns": [ + { + "name": "ID", + "url": "https:\/\/tfs.tfs.ru\/tfs\/Development\/_apis\/wit\/fields\/System.Id", + "referenceName": "System.Id" + }, + { + "name": "Severity", + "url": "https:\/\/tfs.tfs.ru\/tfs\/Development\/_apis\/wit\/fields\/Microsoft.VSTS.Common.Severity", + "referenceName": "Microsoft.VSTS.Common.Severity" + }, + { + "name": "Target Version", + "url": "https:\/\/tfs.tfs.ru\/tfs\/Development\/_apis\/wit\/fields\/TargetVersion", + "referenceName": "TargetVersion" + } + ], + "sortColumns": [ + { + "field": { + "name": "Target Version", + "url": "https:\/\/tfs.tfs.ru\/tfs\/Development\/_apis\/wit\/fields\/TargetVersion", + "referenceName": "TargetVersion" + }, + "descending": false + }, + { + "field": { + "name": "Severity", + "url": "https:\/\/tfs.tfs.ru\/tfs\/Development\/_apis\/wit\/fields\/Microsoft.VSTS.Common.Severity", + "referenceName": "Microsoft.VSTS.Common.Severity" + }, + "descending": false + } + ], + "workItems": [ + { + "url": "https:\/\/tfs.tfs.ru\/tfs\/Development\/_apis\/wit\/workItems\/100", + "id": 100 + }, + { + "url": "https:\/\/tfs.tfs.ru\/tfs\/Development\/_apis\/wit\/workItems\/101", + "id": 101 + } + ], + "asOf": "2017-07-24T06:59:38.74Z", + "queryType": "flat" + }""" + data_ = json.loads(data_str) + wiql = Wiql(data_, tfsapi) + yield wiql + + @pytest.fixture() + def wiql_empty(self, tfsapi): + data_str = r"""{ + "queryResultType": "workItem", + "columns": [ + { + "name": "ID", + "url": "https:\/\/tfs.tfs.ru\/tfs\/Development\/_apis\/wit\/fields\/System.Id", + "referenceName": "System.Id" + }, + { + "name": "Severity", + "url": "https:\/\/tfs.tfs.ru\/tfs\/Development\/_apis\/wit\/fields\/Microsoft.VSTS.Common.Severity", + "referenceName": "Microsoft.VSTS.Common.Severity" + }, + { + "name": "Target Version", + "url": "https:\/\/tfs.tfs.ru\/tfs\/Development\/_apis\/wit\/fields\/TargetVersion", + "referenceName": "TargetVersion" + } + ], + "sortColumns": [ + { + "field": { + "name": "Target Version", + "url": "https:\/\/tfs.tfs.ru\/tfs\/Development\/_apis\/wit\/fields\/TargetVersion", + "referenceName": "TargetVersion" + }, + "descending": false + }, + { + "field": { + "name": "Severity", + "url": "https:\/\/tfs.tfs.ru\/tfs\/Development\/_apis\/wit\/fields\/Microsoft.VSTS.Common.Severity", + "referenceName": "Microsoft.VSTS.Common.Severity" + }, + "descending": false + } + ], + "workItems": [ + ], + "asOf": "2017-07-24T06:59:38.74Z", + "queryType": "flat" + }""" + data_ = json.loads(data_str) + wiql = Wiql(data_, tfsapi) + yield wiql + + def test_wiql(self, wiql): + assert wiql.id is None + assert wiql["queryResultType"] == "workItem" + + def test_get_wiql_workitem_ids(self, wiql): + assert wiql.workitem_ids == [100, 101] + + @pytest.mark.httpretty + def test_get_wiql_workitems(self, wiql): + workitems = wiql.workitems + + assert len(workitems) == 2 + assert workitems[0].id == 100 + assert workitems[1].id == 101 + + def test_wiql_empty(self, wiql_empty): + assert wiql_empty.workitem_ids == [] + + def test_wiql_result(self, wiql): + assert wiql._data == wiql.result diff --git a/tfs/connection.py b/tfs/connection.py index 89df24d..4fd72cd 100644 --- a/tfs/connection.py +++ b/tfs/connection.py @@ -20,10 +20,25 @@ def batch(iterable, n=1): class TFSAPI: - def __init__(self, server_url, project="DefaultCollection", user=None, password=None, verify=False): + def __init__(self, server_url, project="DefaultCollection", user=None, password=None, verify=False, + connect_timeout=20, read_timeout=180): + """ + :param server_url: url to TFS server, e.g. https://tfs.example.com/ + :param project: Collection or Collection\\Project + :param user: username, or DOMAIN\\username + :param password: password + :param verify: True|False - verify HTTPS cert + :param connect_timeout: Requests CONNECTION timeout, sec or None + :param read_timeout: Requests READ timeout, sec or None + """ if user is None or password is None: raise ValueError('User name and api-key must be specified!') - self.rest_client = TFSHTTPClient(server_url, project=project, user=user, password=password, verify=verify) + self.rest_client = TFSHTTPClient(server_url, + project=project, + user=user, password=password, + verify=verify, + timeout=(connect_timeout, read_timeout), + ) def get_tfs_object(self, uri, payload=None, object_class=TFSObject, project=False): """ Send requests and return any object in TFS """ @@ -74,8 +89,7 @@ def get_changesets(self, from_=None, to_=None, item_path=None, top=10000): if item_path: payload['searchCriteria.itemPath'] = item_path - changeset_raw = self.get_tfs_object('tfvc/changesets', payload=payload, object_class=Changeset) - changesets = [Changeset(x, self) for x in changeset_raw] + changesets = self.get_tfs_object('tfvc/changesets', payload=payload, object_class=Changeset) return changesets def get_projects(self): @@ -98,13 +112,22 @@ def run_query(self, path): object_class=TFSQuery) return query + def run_wiql(self, query): + data = {"query": query, } + wiql = self.rest_client.send_post('wit/wiql?api-version=1.0', + data=data, + project=True, + headers={'Content-Type': 'application/json'} + ) + return Wiql(wiql, self) + class TFSClientError(Exception): pass class TFSHTTPClient: - def __init__(self, base_url, project, user, password, verify=False): + def __init__(self, base_url, project, user, password, verify=False, timeout=None): if not base_url.endswith('/'): base_url += '/' @@ -121,6 +144,7 @@ def __init__(self, base_url, project, user, password, verify=False): auth = HTTPBasicAuth(user, password) self.http_session.auth = auth + self.timeout = timeout self._verify = verify if not self._verify: from requests.packages.urllib3.exceptions import InsecureRequestWarning @@ -152,12 +176,15 @@ def send_patch(self, uri, data, headers, project=False): def __send_request(self, method, uri, data, headers=None, payload=None, project=False): url = (self._url_prj if project else self._url) + uri if method == 'POST': - response = self.http_session.post(url, json=data, verify=self._verify, headers=headers) + response = self.http_session.post(url, json=data, verify=self._verify, headers=headers, + timeout=self.timeout) elif method == 'PATCH': - response = self.http_session.patch(url, json=data, verify=self._verify, headers=headers) + response = self.http_session.patch(url, json=data, verify=self._verify, headers=headers, + timeout=self.timeout) else: headers = {'Content-Type': 'application/json'} - response = self.http_session.get(url, headers=headers, verify=self._verify, params=payload) + response = self.http_session.get(url, headers=headers, verify=self._verify, params=payload, + timeout=self.timeout) response.raise_for_status() try: diff --git a/tfs/resources.py b/tfs/resources.py index 3af808c..08d4cc2 100644 --- a/tfs/resources.py +++ b/tfs/resources.py @@ -24,74 +24,67 @@ class TFSObject(object): def __init__(self, data=None, tfs=None, uri=''): # TODO: CaseInsensitive Dict self._data = data - self._attrib = self._data self.tfs = tfs - self._attrib_prefix = '' self.id = self._data.get('id', None) self.uri = uri def __repr__(self): _repr = '' - for k, v in self._attrib.items(): + for k, v in self._data.items(): _repr += to_str(k) + ' = ' + to_str(v) + '\n' return _repr - def __iter__(self): - for item in self._attrib: - attr = self[item] - if isinstance(attr, basestring) or isinstance(attr, list) \ - or getattr(attr, '__iter__', False): - yield item - - def get(self, key, default): - if key in self._attrib: - return self._attrib[key] - key = self._add_prefix(key) - return self._attrib.get(key, default) - def __getitem__(self, key): - if key in self._attrib: - return self._attrib[key] - - key = self._add_prefix(key) - return self._attrib[key] + return self._data[key] def __setitem__(self, key, value): - if key in self._attrib: - self._attrib[key] = value + raise NotImplemented - key = self._add_prefix(key) - self._attrib[key] = value - - def _add_prefix(self, key): - if key.startswith(self._attrib_prefix): - return key - else: - return self._attrib_prefix + key - - def _remove_prefix(self, key): - if key.startswith(self._attrib_prefix): - return key[len(self._attrib_prefix):] - else: - return key + def get(self, key, default=None): + return self._data.get(key, default) class Workitem(TFSObject): def __init__(self, data=None, tfs=None): super().__init__(data, tfs) - self._attrib = CaseInsensitiveDict(self._data['fields']) - self._attrib_prefix = 'System.' + self._fields = CaseInsensitiveDict(self._data['fields']) + self._system_prefix = 'System.' self.id = self._data['id'] def __setitem__(self, key, value): - field_path = "/fields/{}{}".format(self._attrib_prefix, key) + field_path = "/fields/{}{}".format(self._system_prefix, key) update_data = [dict(op="add", path=field_path, value=value)] raw = self.tfs.update_workitem(self.id, update_data) self.__init__(raw, self.tfs) + def get(self, key, default=None): + if key in self._fields: + return self._fields[key] + key = self._add_prefix(key) + return self._fields.get(key, default) + + def __getitem__(self, key): + if key in self._fields: + return self._fields[key] + + key = self._add_prefix(key) + return self._fields[key] + + def _add_prefix(self, key): + if key.startswith(self._system_prefix): + return key + else: + return self._system_prefix + key + + def _remove_prefix(self, key): + if key.startswith(self._system_prefix): + return key[len(self._system_prefix):] + else: + return key + @property def field_names(self): - return [self._remove_prefix(x) for x in self._attrib] + return [self._remove_prefix(x) for x in self._fields] @property def history(self): @@ -166,3 +159,22 @@ def workitems(self): if not self._workitems: self._workitems = self.tfs.get_workitems((i['id'] for i in self.result['workItems'])) return self._workitems + + +class Wiql(TFSObject): + """ + Work Item Query Language + """ + + def __init__(self, data=None, tfs=None): + super().__init__(data, tfs) + self.result = self._data + + @property + def workitem_ids(self): + ids = [x['id'] for x in self._data['workItems']] + return ids + + @property + def workitems(self): + return self.tfs.get_workitems(self.workitem_ids)