Skip to content

Commit 2d70b6c

Browse files
Add pytest testrail API wrapper
1 parent 2c4c50a commit 2d70b6c

20 files changed

+2444
-0
lines changed

pytest_testrail/__init__.py

Whitespace-only changes.

pytest_testrail/_category.py

Lines changed: 974 additions & 0 deletions
Large diffs are not rendered by default.

pytest_testrail/_session.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import os
2+
import sys
3+
4+
import requests
5+
6+
from pytest_testrail.exceptions import MissingCloudCredentialError
7+
8+
# pylint: disable=import-error
9+
if sys.version_info[0] == 2:
10+
import ConfigParser as configparser
11+
else:
12+
import configparser
13+
14+
15+
class Session:
16+
__default_headers = {
17+
'Content-Type': 'application/json',
18+
'User-Agent': 'TestRail API v: 2'
19+
}
20+
21+
def __init__(self, base_url: str = None, email: str = None, key: str = None, **kwargs):
22+
email = email or self.__get_credential('email', ['TESTRAIL_EMAIL'])
23+
key = key or self.__get_credential('key', ['TESTRAIL_KEY'])
24+
base_url = base_url or self.__get_credential('url', ['TESTRAIL_URL'])
25+
verify_ssl = bool(self.__get_credential('verify_ssl', ['TESTRAIL_VERIFY_URL']))
26+
27+
self.__base_url = f'{base_url}/index.php?/api/v2/'
28+
self.__user = email
29+
self.__password = key
30+
self.__headers = kwargs.get('headers', self.__default_headers)
31+
self.__verify_ssl = kwargs.get('verify_ssl', verify_ssl)
32+
self.__timeout = kwargs.get('timeout', 5)
33+
self.__session = requests.Session()
34+
35+
@property
36+
def __name(self):
37+
return type(self).__name__
38+
39+
@property
40+
def __config(self):
41+
name = '.{0}'.format(self.__name.lower())
42+
config = configparser.ConfigParser()
43+
config.read([name, os.path.join(os.path.expanduser('~'), name)])
44+
return config
45+
46+
def __get_credential(self, key, envs):
47+
try:
48+
return self.__config.get('credentials', key)
49+
except (configparser.NoSectionError, configparser.NoOptionError, KeyError):
50+
for env in envs:
51+
value = os.getenv(env)
52+
if value:
53+
return value
54+
raise MissingCloudCredentialError(self.__name, key, envs)
55+
56+
def request(self, method: str, src: str, **kwargs):
57+
"""
58+
Base request method
59+
:param method:
60+
:param src:
61+
:param kwargs:
62+
:return: response
63+
"""
64+
url = f'{self.__base_url}{src}'
65+
response = self.__session.request(method, url, auth=(self.__user, self.__password), headers=self.__headers,
66+
**kwargs)
67+
return response.json()

pytest_testrail/exceptions.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import pytest
2+
3+
4+
class MissingCloudCredentialError(pytest.UsageError):
5+
def __init__(self, driver, key, envs):
6+
super(MissingCloudCredentialError, self).__init__(
7+
"{0} {1} must be set. Try setting one of the following "
8+
"environment variables {2}, or see the documentation for "
9+
"how to use a configuration file.".format(driver, key, envs)
10+
)
11+
12+
13+
class InvalidOptionError(Exception):
14+
def __init__(self, option):
15+
Exception.__init__(self, 'Option %s is not a valid choice' % option)

pytest_testrail/helper.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import re
2+
from datetime import timedelta
3+
4+
5+
class TestRailError(Exception):
6+
pass
7+
8+
9+
class ContainerIter:
10+
def __init__(self, objs):
11+
self._objs = list(objs)
12+
13+
def __len__(self):
14+
return len(self._objs)
15+
16+
def __getitem__(self, index):
17+
return self._objs[index]
18+
19+
20+
CUSTOM_METHODS_RE = re.compile(r'^custom_(\w+)')
21+
22+
23+
def custom_methods(content):
24+
matches = [CUSTOM_METHODS_RE.match(method) for method in content]
25+
return dict({match.string: content[match.string] for match in matches if match})
26+
27+
28+
def testrail_duration_to_timedelta(duration):
29+
span = __span()
30+
timedelta_map = {
31+
'weeks': span(re.search(r'\d+w', duration)),
32+
'days': span(re.search(r'\d+d', duration)),
33+
'hours': span(re.search(r'\d+h', duration)),
34+
'minutes': span(re.search(r'\d+m', duration)),
35+
'seconds': span(re.search(r'\d+s', duration))
36+
}
37+
return timedelta(**timedelta_map)
38+
39+
40+
def __span():
41+
return lambda x: int(x.group(0)[:-1]) if x else 0

pytest_testrail/model/__init__.py

Whitespace-only changes.

pytest_testrail/model/case.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
from datetime import datetime
2+
3+
from pytest_testrail.helper import TestRailError, custom_methods
4+
5+
6+
class Case(object):
7+
def __init__(self, content=None):
8+
self._content = content or dict()
9+
self._custom_methods = custom_methods(self._content)
10+
11+
def __getattr__(self, attr):
12+
if attr in self._custom_methods:
13+
return self._content.get(self._custom_methods[attr])
14+
raise AttributeError('"{}" object has no attribute "{}"'.format(
15+
self.__class__.__name__, attr))
16+
17+
def __str__(self):
18+
return self.title
19+
20+
@property
21+
def created_by(self):
22+
return self._content.get('created_by')
23+
24+
@property
25+
def created_on(self):
26+
return datetime.fromtimestamp(int(self._content.get('created_on')))
27+
28+
@property
29+
def estimate(self):
30+
return self._content.get('estimate')
31+
32+
@estimate.setter
33+
def estimate(self, value):
34+
# TODO should have some logic to validate format of timespan
35+
if not isinstance(value, str):
36+
raise TestRailError('input must be a string')
37+
self._content['estimate'] = value
38+
39+
@property
40+
def estimated_forecast(self):
41+
return self._content.get('estimated_forecast')
42+
43+
@property
44+
def id(self):
45+
return self._content.get('id')
46+
47+
@property
48+
def milestone_id(self):
49+
return self._content.get('milestone_id')
50+
51+
@milestone_id.setter
52+
def milestone_id(self, value):
53+
if not isinstance(value, int):
54+
raise TestRailError('input must be an int')
55+
self._content['milestone_id'] = value
56+
57+
@property
58+
def priority_id(self):
59+
return self._content.get('priority_id')
60+
61+
@priority_id.setter
62+
def priority_id(self, value):
63+
if not isinstance(value, int):
64+
raise TestRailError('input must be an int')
65+
self._content['priority_id'] = value
66+
67+
@property
68+
def refs(self):
69+
refs = self._content.get('refs')
70+
return refs.split(',') if refs else list()
71+
72+
@refs.setter
73+
def refs(self, value):
74+
if not isinstance(value, list):
75+
raise TestRailError('input must be a list')
76+
self._content['refs'] = ','.join(value)
77+
78+
@property
79+
def section_id(self):
80+
return self._content.get('section_id')
81+
82+
@section_id.setter
83+
def section_id(self, value):
84+
if not isinstance(value, int):
85+
raise TestRailError('input must be an int')
86+
self._content['section_id'] = value
87+
88+
@property
89+
def suite_id(self):
90+
return self._content.get('suite_id')
91+
92+
@suite_id.setter
93+
def suite_id(self, value):
94+
if not isinstance(value, int):
95+
raise TestRailError('input must be an int')
96+
self._content['suite_id'] = value
97+
98+
@property
99+
def template_id(self):
100+
return self._content.get('template_id')
101+
102+
@template_id.setter
103+
def template_id(self, value):
104+
if not isinstance(value, int):
105+
raise TestRailError('input must be an id')
106+
self._content['template_id'] = value
107+
108+
@property
109+
def title(self):
110+
return self._content.get('title')
111+
112+
@title.setter
113+
def title(self, value):
114+
if not isinstance(value, (str, str)):
115+
raise TestRailError('input must be a string')
116+
self._content['title'] = value
117+
118+
@property
119+
def type_id(self):
120+
return self._content.get('type_id')
121+
122+
@type_id.setter
123+
def type_id(self, value):
124+
if not isinstance(value, int):
125+
raise TestRailError('input must be an id')
126+
self._content['type_id'] = value
127+
128+
@property
129+
def updated_by(self):
130+
return self._content.get('updated_by')
131+
132+
@property
133+
def updated_on(self):
134+
return datetime.fromtimestamp(int(self._content.get('updated_on')))
135+
136+
def raw_data(self):
137+
return self._content

pytest_testrail/model/case_type.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
class CaseType(object):
2+
def __init__(self, content):
3+
self._content = content
4+
5+
def __str__(self):
6+
return self.name
7+
8+
@property
9+
def id(self):
10+
return self._content.get('id')
11+
12+
@property
13+
def is_default(self):
14+
return self._content.get('is_default')
15+
16+
@property
17+
def name(self):
18+
return self._content.get('name')

0 commit comments

Comments
 (0)