python-backlog 0.7.2__tar.gz → 0.7.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {python_backlog-0.7.2/python_backlog.egg-info → python_backlog-0.7.6}/PKG-INFO +17 -2
  2. {python_backlog-0.7.2 → python_backlog-0.7.6}/README.md +2 -0
  3. python_backlog-0.7.6/VERSION +1 -0
  4. {python_backlog-0.7.2 → python_backlog-0.7.6}/backlog/attachment.py +6 -4
  5. {python_backlog-0.7.2 → python_backlog-0.7.6}/backlog/base.py +52 -55
  6. {python_backlog-0.7.2 → python_backlog-0.7.6}/backlog/issue.py +1 -1
  7. {python_backlog-0.7.2 → python_backlog-0.7.6}/backlog/project.py +12 -5
  8. {python_backlog-0.7.2 → python_backlog-0.7.6}/backlog/user.py +4 -4
  9. {python_backlog-0.7.2 → python_backlog-0.7.6}/backlog/wiki.py +18 -1
  10. {python_backlog-0.7.2 → python_backlog-0.7.6/python_backlog.egg-info}/PKG-INFO +17 -2
  11. {python_backlog-0.7.2 → python_backlog-0.7.6}/python_backlog.egg-info/SOURCES.txt +1 -0
  12. {python_backlog-0.7.2 → python_backlog-0.7.6}/tests/test_api.py +83 -1
  13. python_backlog-0.7.6/tests/test_request_params.py +193 -0
  14. {python_backlog-0.7.2 → python_backlog-0.7.6}/tests/test_wiki.py +2 -1
  15. python_backlog-0.7.2/VERSION +0 -1
  16. {python_backlog-0.7.2 → python_backlog-0.7.6}/LICENSE +0 -0
  17. {python_backlog-0.7.2 → python_backlog-0.7.6}/MANIFEST.in +0 -0
  18. {python_backlog-0.7.2 → python_backlog-0.7.6}/backlog/__init__.py +0 -0
  19. {python_backlog-0.7.2 → python_backlog-0.7.6}/backlog/category.py +0 -0
  20. {python_backlog-0.7.2 → python_backlog-0.7.6}/backlog/custom_field.py +0 -0
  21. {python_backlog-0.7.2 → python_backlog-0.7.6}/backlog/git.py +0 -0
  22. {python_backlog-0.7.2 → python_backlog-0.7.6}/python_backlog.egg-info/dependency_links.txt +0 -0
  23. {python_backlog-0.7.2 → python_backlog-0.7.6}/python_backlog.egg-info/requires.txt +0 -0
  24. {python_backlog-0.7.2 → python_backlog-0.7.6}/python_backlog.egg-info/top_level.txt +0 -0
  25. {python_backlog-0.7.2 → python_backlog-0.7.6}/requirements-dev.txt +0 -0
  26. {python_backlog-0.7.2 → python_backlog-0.7.6}/requirements.txt +0 -0
  27. {python_backlog-0.7.2 → python_backlog-0.7.6}/setup.cfg +0 -0
  28. {python_backlog-0.7.2 → python_backlog-0.7.6}/setup.py +0 -0
  29. {python_backlog-0.7.2 → python_backlog-0.7.6}/tests/test_attachment.py +0 -0
  30. {python_backlog-0.7.2 → python_backlog-0.7.6}/tests/test_category.py +0 -0
  31. {python_backlog-0.7.2 → python_backlog-0.7.6}/tests/test_custom_filed.py +0 -0
  32. {python_backlog-0.7.2 → python_backlog-0.7.6}/tests/test_git.py +0 -0
  33. {python_backlog-0.7.2 → python_backlog-0.7.6}/tests/test_issue.py +0 -0
  34. {python_backlog-0.7.2 → python_backlog-0.7.6}/tests/test_project.py +0 -0
  35. {python_backlog-0.7.2 → python_backlog-0.7.6}/tests/test_user.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: python-backlog
3
- Version: 0.7.2
3
+ Version: 0.7.6
4
4
  Summary: Backlog API v2 wrapper
5
5
  Home-page: https://github.com/hassaku63/pbl
6
6
  Author: hassaku63
@@ -19,6 +19,19 @@ Requires-Python: >=3.8
19
19
  Description-Content-Type: text/markdown
20
20
  License-File: LICENSE
21
21
  Requires-Dist: requests~=2.32.2
22
+ Dynamic: author
23
+ Dynamic: author-email
24
+ Dynamic: classifier
25
+ Dynamic: description
26
+ Dynamic: description-content-type
27
+ Dynamic: home-page
28
+ Dynamic: keywords
29
+ Dynamic: license
30
+ Dynamic: license-file
31
+ Dynamic: project-url
32
+ Dynamic: requires-dist
33
+ Dynamic: requires-python
34
+ Dynamic: summary
22
35
 
23
36
  # pbl
24
37
 
@@ -44,6 +57,8 @@ def main():
44
57
  """
45
58
  Initialize API Object
46
59
  """
60
+ # The API key is sent in the "Backlog-API-Key" request header.
61
+ # Requests time out after 30 seconds by default (configurable with `timeout=`).
47
62
  api = BacklogAPI("your-space", "your-api-key")
48
63
 
49
64
  """
@@ -22,6 +22,8 @@ def main():
22
22
  """
23
23
  Initialize API Object
24
24
  """
25
+ # The API key is sent in the "Backlog-API-Key" request header.
26
+ # Requests time out after 30 seconds by default (configurable with `timeout=`).
25
27
  api = BacklogAPI("your-space", "your-api-key")
26
28
 
27
29
  """
@@ -0,0 +1 @@
1
+ 0.7.6
@@ -20,10 +20,12 @@ class Attachment(object):
20
20
 
21
21
  _uri = "space/attachment"
22
22
  _method = "POST"
23
- _files = {
24
- 'file': (os.path.basename(filename), open(filename, 'rb'), mimetypes.guess_type(filename))
25
- }
23
+ _content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
26
24
 
27
- resp = self.api.invoke_method(_method, _uri, files=_files)
25
+ with open(filename, 'rb') as f:
26
+ _files = {
27
+ 'file': (os.path.basename(filename), f, _content_type)
28
+ }
29
+ resp = self.api.invoke_method(_method, _uri, files=_files)
28
30
 
29
31
  return resp.json()
@@ -1,6 +1,6 @@
1
1
  # coding: utf-8
2
2
 
3
- from urllib.parse import urlencode
3
+ import re
4
4
  import requests
5
5
  from backlog.wiki import Wiki
6
6
  from backlog.attachment import Attachment
@@ -13,6 +13,12 @@ from backlog.custom_field import CustomField
13
13
 
14
14
  BACKLOG_BASE_URL = "https://{space}.backlog.jp"
15
15
  BACKLOG_URI_PREFIX = "/api/v2/"
16
+ BACKLOG_API_KEY_HEADER = "Backlog-API-Key"
17
+ DEFAULT_TIMEOUT = 30
18
+
19
+ # Space keys consist of alphanumerics and hyphens. Validating it prevents the
20
+ # base URL (and therefore the API key) from being pointed at an arbitrary host.
21
+ _SPACE_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]*$")
16
22
 
17
23
 
18
24
  class BacklogError(Exception):
@@ -20,9 +26,18 @@ class BacklogError(Exception):
20
26
 
21
27
 
22
28
  class BaseAPI(object):
23
- def __init__(self, space, api_key=None):
29
+ def __init__(self, space, api_key=None, timeout=DEFAULT_TIMEOUT):
30
+ """
31
+ :param space: Backlog space key (the "xxx" of https://xxx.backlog.jp)
32
+ :param api_key: API key. It is sent in the "Backlog-API-Key" request header.
33
+ :param timeout: Default timeout (seconds) passed to the HTTP client.
34
+ Can be overridden per request with the "timeout" keyword argument. None disables it.
35
+ """
36
+ if not isinstance(space, str) or not _SPACE_PATTERN.match(space):
37
+ raise ValueError("Invalid space key: {!r}".format(space))
24
38
  self.space = space
25
39
  self.api_key = api_key
40
+ self.timeout = timeout
26
41
  self.base_url = BACKLOG_BASE_URL.format(space=space) + BACKLOG_URI_PREFIX
27
42
  self.api = None
28
43
  self._build_api()
@@ -30,7 +45,18 @@ class BaseAPI(object):
30
45
  def _build_api(self):
31
46
  self.api = None
32
47
 
33
- def invoke_method(self, method, uri, query_param={}, request_param={}, headers=None, **kwargs):
48
+ def _build_headers(self, headers=None):
49
+ """
50
+ Merge authentication headers into the given headers without mutating it.
51
+ """
52
+ _headers = {}
53
+ if self.api_key is not None:
54
+ _headers[BACKLOG_API_KEY_HEADER] = self.api_key
55
+ if headers:
56
+ _headers.update(headers)
57
+ return _headers
58
+
59
+ def invoke_method(self, method, uri, query_param=None, request_param=None, headers=None, **kwargs):
34
60
  """
35
61
  Delegate requests to get/post/delete method.
36
62
  Each method implements by any http client, default client is requests.
@@ -39,10 +65,14 @@ class BaseAPI(object):
39
65
  :param uri: API Endpoint. 'uri' expects a URI excluded '/api/v2/' prefix
40
66
  :param query_param: dict of URL parameter. this will be url-encoded string
41
67
  :param request_param: dict of body parameter. this will use when invoke post request
42
- :param headers: HTTP Header dict. Recommend value is None. if None, then use default implements of http client(requests).
68
+ :param headers: HTTP Header dict. Authentication headers are added automatically.
43
69
  :return: http response object
44
70
  """
45
- query_param.setdefault("apiKey", self.api_key)
71
+ # Copy the arguments so that neither the caller's dicts nor any shared state are mutated.
72
+ query_param = dict(query_param or {})
73
+ request_param = dict(request_param or {})
74
+ headers = self._build_headers(headers)
75
+ kwargs.setdefault("timeout", self.timeout)
46
76
 
47
77
  method = method.lower()
48
78
 
@@ -100,64 +130,31 @@ class BacklogAPI(BaseAPI):
100
130
  self.category = Category(self)
101
131
  self.custom_field = CustomField(self)
102
132
 
103
- def get(self, method, uri, query_param, request_param, headers, **kwargs):
104
- _url = self.base_url + uri
105
- if headers is not None:
106
- resp = requests.get(_url, params=query_param, headers=headers, **kwargs)
107
- else:
108
- resp = requests.get(_url, params=query_param, **kwargs)
109
-
110
- if resp.status_code // 100 == 2:
133
+ @staticmethod
134
+ def _raise_for_status(resp, ok):
135
+ if ok(resp.status_code):
111
136
  return resp
112
-
113
137
  raise BacklogError("Http response {status}: {message}".format(status=resp.status_code, message=resp.text))
114
138
 
115
- def post(self, method, uri, query_param, request_param, headers, **kwargs):
116
- _url = self.base_url + uri + "?" + urlencode(query_param)
117
- if headers is not None:
118
- resp = requests.post(_url, data=request_param, headers=headers, **kwargs)
119
- else:
120
- resp = requests.post(_url, data=request_param, **kwargs)
121
-
122
- if resp.status_code // 100 == 2:
123
- return resp
139
+ def get(self, method, uri, query_param, request_param, headers, **kwargs):
140
+ _url = self.base_url + uri
141
+ resp = requests.get(_url, params=query_param, headers=headers, **kwargs)
142
+ return self._raise_for_status(resp, lambda code: code // 100 == 2)
124
143
 
125
- raise BacklogError("Http response {status}: {message}".format(status=resp.status_code, message=resp.text))
144
+ def post(self, method, uri, query_param, request_param, headers, **kwargs):
145
+ _url = self.base_url + uri
146
+ resp = requests.post(_url, params=query_param, data=request_param, headers=headers, **kwargs)
147
+ return self._raise_for_status(resp, lambda code: code // 100 == 2)
126
148
 
127
149
  def delete(self, method, uri, query_param, request_param, headers, **kwargs):
128
- _url = self.base_url + uri + "?" + urlencode(query_param)
129
- if headers is not None:
130
- resp = requests.delete(_url, headers=headers, **kwargs)
131
- else:
132
- resp = requests.delete(_url, **kwargs)
133
-
134
- if resp.status_code == 200:
135
- return resp
136
-
137
- raise BacklogError("Http response {status}: {message}".format(status=resp.status_code, message=resp.text))
150
+ _url = self.base_url + uri
151
+ resp = requests.delete(_url, params=query_param, data=request_param, headers=headers, **kwargs)
152
+ return self._raise_for_status(resp, lambda code: code // 100 == 2)
138
153
 
139
154
  def patch(self, method, uri, query_param, request_param, headers, **kwargs):
140
- """
141
- TODO: Implementation.
142
-
143
- :param method:
144
- :param uri:
145
- :param query_param:
146
- :param request_param:
147
- :param headers:
148
- :param kwargs:
149
- :return:
150
- """
151
- _url = self.base_url + uri + "?" + urlencode(query_param)
152
- if headers is not None:
153
- resp = requests.ptach(_url, data=request_param, headers=headers, **kwargs)
154
- else:
155
- resp = requests.patch(_url, data=request_param, **kwargs)
156
-
157
- if resp.status_code == 200:
158
- return resp
159
-
160
- raise BacklogError("Http response {status}: {message}".format(status=resp.status_code, message=resp.text))
155
+ _url = self.base_url + uri
156
+ resp = requests.patch(_url, params=query_param, data=request_param, headers=headers, **kwargs)
157
+ return self._raise_for_status(resp, lambda code: code // 100 == 2)
161
158
 
162
159
 
163
160
  class BacklogObject(object):
@@ -124,7 +124,7 @@ class Issue(object):
124
124
  if attachmentId is not None:
125
125
  _data.update([("attachmentId[]", attachmentId)])
126
126
 
127
- resp = self.api.invoke_method(_method, _uri, _data)
127
+ resp = self.api.invoke_method(_method, _uri, request_param=_data)
128
128
 
129
129
  return resp.json()
130
130
 
@@ -72,6 +72,7 @@ class Project(object):
72
72
  useOriginalImageSizeAtWiki: Optional[bool] = None,
73
73
  textFormattingRule: Optional[TextFormattingRule] = None,
74
74
  useDevAttributes: Optional[bool] = None,
75
+ chartEnabled: Optional[bool] = None,
75
76
  ) -> AddProjectResponse:
76
77
  """Add project
77
78
  https://developer.nulab.com/docs/backlog/api/2/add-project/#form-parameters
@@ -80,8 +81,10 @@ class Project(object):
80
81
  :type name: str
81
82
  :param key: Project Key (Uppercase letters (A-Z), numbers (0-9) and underscore (_) can be used.)
82
83
  :type key: str
83
- :param chatEnabled: Enable chart
84
+ :param chatEnabled: Deprecated alias of chartEnabled, kept for backward compatibility
84
85
  :type chatEnabled: bool
86
+ :param chartEnabled: Enable chart
87
+ :type chartEnabled: bool
85
88
  :param useResolvedForChart: Consider Resolved and statuses after as Closed
86
89
  :type useResolvedForChart: bool
87
90
  :param subtaskingEnabled: Enable subtasking
@@ -109,10 +112,14 @@ class Project(object):
109
112
  """
110
113
  _path = "projects"
111
114
  _method = "POST"
115
+ if chartEnabled is not None and chatEnabled is not None and chartEnabled != chatEnabled:
116
+ raise ValueError("chartEnabled and its deprecated alias chatEnabled have conflicting values")
117
+ if chartEnabled is None:
118
+ chartEnabled = chatEnabled
112
119
  request_param = {
113
120
  "name": name,
114
121
  "key": key,
115
- "chartEnabled": chatEnabled,
122
+ "chartEnabled": chartEnabled,
116
123
  "useResolvedForChart": useResolvedForChart,
117
124
  "subtaskingEnabled": subtaskingEnabled,
118
125
  "projectLeaderCanEditProjectLeader": projectLeaderCanEditProjectLeader,
@@ -241,7 +248,7 @@ class Project(object):
241
248
  _uri = "projects/{project_id_or_key}/users".format(project_id_or_key=projectIdOrKey)
242
249
  _method = "GET"
243
250
  _query_param = {
244
- "excludeGroupMembers": "ture" if excludeGroupMembers else "false"
251
+ "excludeGroupMembers": "true" if excludeGroupMembers else "false"
245
252
  }
246
253
 
247
254
  resp = self.api.invoke_method(_method, _uri, query_param=_query_param)
@@ -361,7 +368,7 @@ class Project(object):
361
368
  )
362
369
  _method = "POST"
363
370
 
364
- resp = self.api.invoke_method(_method, _uri)
371
+ resp = self.api.invoke_method(_method, _uri, request_param={"name": name})
365
372
 
366
373
  return resp.json()
367
374
 
@@ -380,7 +387,7 @@ class Project(object):
380
387
  )
381
388
  _method = "PATCH"
382
389
 
383
- resp = self.api.invoke_method(_method, _uri)
390
+ resp = self.api.invoke_method(_method, _uri, request_param={"name": name})
384
391
 
385
392
  return resp.json()
386
393
 
@@ -51,7 +51,7 @@ class User(object):
51
51
  _method = "POST"
52
52
  params = dict(
53
53
  userId=userId,
54
- passowrd=password,
54
+ password=password,
55
55
  name=name,
56
56
  mailAddress=mailAddress,
57
57
  roleType=roleType
@@ -61,7 +61,7 @@ class User(object):
61
61
 
62
62
  return resp.json()
63
63
 
64
- def update(self, userId, password, name, mailAddress, roleType):
64
+ def update(self, userId, password=None, name=None, mailAddress=None, roleType=None):
65
65
  """
66
66
  https://developer.nulab.com/ja/docs/backlog/api/2/update-user/
67
67
 
@@ -75,12 +75,12 @@ class User(object):
75
75
  _uri = "users/{user_id}".format(user_id=userId)
76
76
  _method = "PATCH"
77
77
  params = dict(
78
- userId=userId,
79
- passowrd=password,
78
+ password=password,
80
79
  name=name,
81
80
  mailAddress=mailAddress,
82
81
  roleType=roleType
83
82
  )
83
+ params = {k: v for k, v in params.items() if v is not None}
84
84
 
85
85
  resp = self.api.invoke_method(_method, _uri, request_param=params)
86
86
 
@@ -1,5 +1,22 @@
1
1
  # coding: utf-8
2
2
 
3
+ from email.message import Message
4
+
5
+
6
+ def _filename_from_response(resp):
7
+ """
8
+ Extract the file name from the Content-Disposition header (supports RFC 6266 filename*).
9
+
10
+ :return: file name, or None if the header does not contain one
11
+ """
12
+ _content_disposition = resp.headers.get("Content-Disposition")
13
+ if not _content_disposition:
14
+ return None
15
+ _message = Message()
16
+ _message["Content-Disposition"] = _content_disposition
17
+ return _message.get_filename()
18
+
19
+
3
20
  class Wiki(object):
4
21
  def __init__(self, api):
5
22
  """
@@ -146,7 +163,7 @@ class Wiki(object):
146
163
 
147
164
  return {
148
165
  "id": attachmentId,
149
- "name": "test",
166
+ "name": _filename_from_response(resp),
150
167
  "data": resp.content
151
168
  }
152
169
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: python-backlog
3
- Version: 0.7.2
3
+ Version: 0.7.6
4
4
  Summary: Backlog API v2 wrapper
5
5
  Home-page: https://github.com/hassaku63/pbl
6
6
  Author: hassaku63
@@ -19,6 +19,19 @@ Requires-Python: >=3.8
19
19
  Description-Content-Type: text/markdown
20
20
  License-File: LICENSE
21
21
  Requires-Dist: requests~=2.32.2
22
+ Dynamic: author
23
+ Dynamic: author-email
24
+ Dynamic: classifier
25
+ Dynamic: description
26
+ Dynamic: description-content-type
27
+ Dynamic: home-page
28
+ Dynamic: keywords
29
+ Dynamic: license
30
+ Dynamic: license-file
31
+ Dynamic: project-url
32
+ Dynamic: requires-dist
33
+ Dynamic: requires-python
34
+ Dynamic: summary
22
35
 
23
36
  # pbl
24
37
 
@@ -44,6 +57,8 @@ def main():
44
57
  """
45
58
  Initialize API Object
46
59
  """
60
+ # The API key is sent in the "Backlog-API-Key" request header.
61
+ # Requests time out after 30 seconds by default (configurable with `timeout=`).
47
62
  api = BacklogAPI("your-space", "your-api-key")
48
63
 
49
64
  """
@@ -27,5 +27,6 @@ tests/test_custom_filed.py
27
27
  tests/test_git.py
28
28
  tests/test_issue.py
29
29
  tests/test_project.py
30
+ tests/test_request_params.py
30
31
  tests/test_user.py
31
32
  tests/test_wiki.py
@@ -3,7 +3,8 @@
3
3
  import unittest
4
4
  import httpretty
5
5
  import json
6
- from backlog.base import BacklogAPI
6
+ from unittest import mock
7
+ from backlog.base import BacklogAPI, DEFAULT_TIMEOUT
7
8
 
8
9
  API_ENDPOINT = "https://{space}.backlog.jp/api/v2/{uri}"
9
10
 
@@ -115,6 +116,87 @@ class TestApi(unittest.TestCase):
115
116
  self.assertEqual(200, resp.status_code)
116
117
  self.assertEqual(expects, resp.json())
117
118
 
119
+ @httpretty.activate
120
+ def test_api_key_is_sent_in_header_not_query(self):
121
+ for _method in ["GET", "POST", "DELETE", "PATCH"]:
122
+ with self.subTest(method=_method):
123
+ httpretty.register_uri(
124
+ getattr(httpretty, _method),
125
+ API_ENDPOINT.format(space=self.space, uri="test"),
126
+ body="{}",
127
+ status=200
128
+ )
129
+
130
+ self.api.invoke_method(_method, "test")
131
+
132
+ req = httpretty.last_request()
133
+ self.assertEqual(self.api_key, req.headers.get("Backlog-API-Key"))
134
+ self.assertNotIn("apiKey", req.querystring)
135
+ self.assertNotIn(self.api_key, req.path)
136
+
137
+ @httpretty.activate
138
+ def test_api_key_does_not_leak_between_instances(self):
139
+ """Regression: a shared mutable default argument used to keep the first instance's API key."""
140
+ for _space in ["space-a", "space-b"]:
141
+ httpretty.register_uri(
142
+ httpretty.GET,
143
+ API_ENDPOINT.format(space=_space, uri="users"),
144
+ body="[]",
145
+ status=200
146
+ )
147
+ api_a = BacklogAPI("space-a", "KEY_A")
148
+ api_b = BacklogAPI("space-b", "KEY_B")
149
+
150
+ api_a.invoke_method("GET", "users")
151
+ api_b.invoke_method("GET", "users")
152
+
153
+ req = httpretty.last_request()
154
+ self.assertEqual("space-b.backlog.jp", req.headers.get("Host"))
155
+ self.assertEqual("KEY_B", req.headers.get("Backlog-API-Key"))
156
+
157
+ @httpretty.activate
158
+ def test_caller_arguments_are_not_mutated(self):
159
+ httpretty.register_uri(
160
+ httpretty.POST,
161
+ API_ENDPOINT.format(space=self.space, uri="test"),
162
+ body="{}",
163
+ status=200
164
+ )
165
+ query_param = {"q": "1"}
166
+ request_param = {"name": "x"}
167
+ headers = {"X-Test": "1"}
168
+
169
+ self.api.invoke_method("POST", "test", query_param=query_param, request_param=request_param, headers=headers)
170
+
171
+ self.assertEqual({"q": "1"}, query_param)
172
+ self.assertEqual({"name": "x"}, request_param)
173
+ self.assertEqual({"X-Test": "1"}, headers)
174
+ req = httpretty.last_request()
175
+ self.assertEqual("1", req.headers.get("X-Test"))
176
+ self.assertEqual(self.api_key, req.headers.get("Backlog-API-Key"))
177
+
178
+ def test_timeout(self):
179
+ resp = mock.Mock(status_code=200)
180
+ with mock.patch("backlog.base.requests.get", return_value=resp) as m:
181
+ self.api.invoke_method("GET", "test")
182
+ self.assertEqual(DEFAULT_TIMEOUT, m.call_args.kwargs["timeout"])
183
+
184
+ BacklogAPI("space", "api_key", timeout=5).invoke_method("GET", "test")
185
+ self.assertEqual(5, m.call_args.kwargs["timeout"])
186
+
187
+ self.api.invoke_method("GET", "test", timeout=1)
188
+ self.assertEqual(1, m.call_args.kwargs["timeout"])
189
+
190
+ def test_invalid_space(self):
191
+ for _space in ["", "evil.example.com/#", "space.backlog.com", "a b", "-space", None]:
192
+ with self.subTest(space=_space):
193
+ with self.assertRaises(ValueError):
194
+ BacklogAPI(_space, "api_key")
195
+
196
+ for _space in ["space", "my-space", "Space01"]:
197
+ with self.subTest(space=_space):
198
+ BacklogAPI(_space, "api_key")
199
+
118
200
 
119
201
  if __name__ == "__main__":
120
202
  unittest.main(warnings='ignore')
@@ -0,0 +1,193 @@
1
+ # coding: utf-8
2
+ """
3
+ Tests that assert what is actually sent to the Backlog API (method, query string and body),
4
+ not only how responses are parsed.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import tempfile
10
+ import unittest
11
+ from urllib.parse import parse_qs
12
+
13
+ import httpretty
14
+
15
+ from backlog.base import BacklogAPI
16
+
17
+ API_ENDPOINT = "https://{space}.backlog.jp/api/v2/{uri}"
18
+
19
+
20
+ def _form(req):
21
+ return {k: v if len(v) > 1 else v[0] for k, v in parse_qs(req.body.decode()).items()}
22
+
23
+
24
+ class TestRequestParams(unittest.TestCase):
25
+ def setUp(self):
26
+ self.space = "space"
27
+ self.api = BacklogAPI(self.space, "api_key")
28
+
29
+ def _register(self, method, uri, body="{}", **kwargs):
30
+ httpretty.register_uri(
31
+ method,
32
+ API_ENDPOINT.format(space=self.space, uri=uri),
33
+ body=body,
34
+ content_type="application/json",
35
+ **kwargs
36
+ )
37
+
38
+ @httpretty.activate
39
+ def test_delete_sends_body(self):
40
+ self._register(httpretty.DELETE, "projects/1/issueTypes/2")
41
+
42
+ self.api.project.delete_issue_type(projectIdOrKey=1, id=2, substituteIssueTypeId=3)
43
+
44
+ self.assertEqual({"substituteIssueTypeId": "3"}, _form(httpretty.last_request()))
45
+
46
+ @httpretty.activate
47
+ def test_wiki_delete_sends_mail_notify(self):
48
+ self._register(httpretty.DELETE, "wikis/1")
49
+
50
+ self.api.wiki.delete(wikiId=1, mailNotify=True)
51
+
52
+ self.assertEqual({"mailNotify": "true"}, _form(httpretty.last_request()))
53
+
54
+ @httpretty.activate
55
+ def test_2xx_responses_are_success_for_delete_and_patch(self):
56
+ for _method in [httpretty.DELETE, httpretty.PATCH]:
57
+ with self.subTest(method=_method):
58
+ self._register(_method, "test", status=202)
59
+ resp = self.api.invoke_method(_method, "test")
60
+ self.assertEqual(202, resp.status_code)
61
+
62
+ @httpretty.activate
63
+ def test_add_comment_sends_content_in_body(self):
64
+ self._register(httpretty.POST, "issues/PRJ-1/comments")
65
+
66
+ self.api.issue.add_comment("PRJ-1", "hello", notifiedUserId=10, attachmentId=20)
67
+
68
+ req = httpretty.last_request()
69
+ self.assertEqual({}, req.querystring)
70
+ self.assertEqual(
71
+ {"content": "hello", "notifiedUserId[]": "10", "attachmentId[]": "20"},
72
+ _form(req)
73
+ )
74
+
75
+ @httpretty.activate
76
+ def test_project_add_category_sends_name(self):
77
+ self._register(httpretty.POST, "projects/1/categories")
78
+
79
+ self.api.project.add_category(projectIdOrKey=1, name="dev")
80
+
81
+ self.assertEqual({"name": "dev"}, _form(httpretty.last_request()))
82
+
83
+ @httpretty.activate
84
+ def test_project_update_category_sends_name(self):
85
+ self._register(httpretty.PATCH, "projects/1/categories/2")
86
+
87
+ self.api.project.update_category(projectIdOrKey=1, id=2, name="dev")
88
+
89
+ self.assertEqual({"name": "dev"}, _form(httpretty.last_request()))
90
+
91
+ @httpretty.activate
92
+ def test_project_list_users_exclude_group_members(self):
93
+ self._register(httpretty.GET, "projects/1/users", body="[]")
94
+
95
+ for _flag, _expected in [(True, "true"), (False, "false")]:
96
+ with self.subTest(excludeGroupMembers=_flag):
97
+ self.api.project.list_users(1, excludeGroupMembers=_flag)
98
+ self.assertEqual([_expected], httpretty.last_request().querystring["excludeGroupMembers"])
99
+
100
+ @httpretty.activate
101
+ def test_project_create_chart_enabled(self):
102
+ self._register(httpretty.POST, "projects")
103
+
104
+ for _kwargs in [{"chartEnabled": True}, {"chatEnabled": True}]:
105
+ with self.subTest(**_kwargs):
106
+ self.api.project.create(name="n", key="K", **_kwargs)
107
+ self.assertEqual("True", _form(httpretty.last_request())["chartEnabled"])
108
+
109
+ with self.assertRaises(ValueError):
110
+ self.api.project.create(name="n", key="K", chartEnabled=True, chatEnabled=False)
111
+
112
+ @httpretty.activate
113
+ def test_user_add_sends_password(self):
114
+ self._register(httpretty.POST, "users")
115
+
116
+ self.api.user.add(userId="u", password="p", name="n", mailAddress="m@example.com", roleType=2)
117
+
118
+ self.assertEqual(
119
+ {"userId": "u", "password": "p", "name": "n", "mailAddress": "m@example.com", "roleType": "2"},
120
+ _form(httpretty.last_request())
121
+ )
122
+
123
+ @httpretty.activate
124
+ def test_user_update_sends_only_given_fields(self):
125
+ self._register(httpretty.PATCH, "users/1")
126
+
127
+ self.api.user.update(1, password="p")
128
+
129
+ self.assertEqual({"password": "p"}, _form(httpretty.last_request()))
130
+
131
+ @httpretty.activate
132
+ def test_wiki_get_attachment_name(self):
133
+ _uri = "wikis/1/attachments/2"
134
+ cases = [
135
+ ("attachment;filename*=UTF-8''%E8%B3%87%E6%96%99.pdf", "資料.pdf"),
136
+ ('attachment; filename="report.pdf"', "report.pdf"),
137
+ (None, None),
138
+ ]
139
+ for _header, _expected in cases:
140
+ with self.subTest(header=_header):
141
+ _headers = {"Content-Disposition": _header} if _header else {}
142
+ httpretty.register_uri(
143
+ httpretty.GET,
144
+ API_ENDPOINT.format(space=self.space, uri=_uri),
145
+ body=b"data",
146
+ content_type="application/octet-stream",
147
+ adding_headers=_headers
148
+ )
149
+
150
+ resp = self.api.wiki.get_attachment(wikiId=1, attachmentId=2)
151
+
152
+ self.assertEqual(_expected, resp["name"])
153
+ self.assertEqual(b"data", resp["data"])
154
+
155
+ @httpretty.activate
156
+ def test_attachment_upload_closes_file_and_sends_content_type(self):
157
+ self._register(httpretty.POST, "space/attachment", body=json.dumps({"id": 1}))
158
+ with tempfile.TemporaryDirectory() as d:
159
+ _path = os.path.join(d, "a.txt")
160
+ with open(_path, "w") as f:
161
+ f.write("hello")
162
+
163
+ with self.assertNoResourceWarning():
164
+ self.api.attachment.upload(_path)
165
+
166
+ body = httpretty.last_request().body
167
+ self.assertIn(b'filename="a.txt"', body)
168
+ self.assertIn(b"Content-Type: text/plain", body)
169
+
170
+ def assertNoResourceWarning(self):
171
+ test = self
172
+
173
+ class _Ctx:
174
+ def __enter__(self):
175
+ import warnings
176
+ import gc
177
+ self._gc = gc
178
+ self._cm = warnings.catch_warnings(record=True)
179
+ self._w = self._cm.__enter__()
180
+ warnings.simplefilter("always", ResourceWarning)
181
+ return self
182
+
183
+ def __exit__(self, *exc):
184
+ self._gc.collect()
185
+ self._cm.__exit__(*exc)
186
+ test.assertEqual([], [w for w in self._w if issubclass(w.category, ResourceWarning)])
187
+ return False
188
+
189
+ return _Ctx()
190
+
191
+
192
+ if __name__ == "__main__":
193
+ unittest.main()
@@ -468,7 +468,8 @@ class TestWiki(unittest.TestCase):
468
468
  httpretty.GET,
469
469
  API_ENDPOINT.format(space=self.space, uri=_uri),
470
470
  body=tmp.file.read(),
471
- content_type="application/octet-stream"
471
+ content_type="application/octet-stream",
472
+ adding_headers={"Content-Disposition": "attachment;filename*=UTF-8''{}".format(_name)}
472
473
  )
473
474
 
474
475
  # test
@@ -1 +0,0 @@
1
- 0.7.2
File without changes
File without changes
File without changes