python-backlog 0.8.1__py3-none-any.whl → 0.9.0__py3-none-any.whl

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.
backlog/_http.py ADDED
@@ -0,0 +1,204 @@
1
+ # coding: utf-8
2
+ """
3
+ Minimal HTTP client built on the standard library (urllib).
4
+
5
+ python-backlog has no runtime dependencies. This module implements the small subset of HTTP
6
+ features the Backlog API needs: query strings, form-encoded and multipart bodies, custom
7
+ headers, timeouts and JSON responses.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import ssl
13
+ import urllib.error
14
+ import urllib.parse
15
+ import urllib.request
16
+ import uuid
17
+ from email.message import Message
18
+
19
+
20
+ class Response(object):
21
+ """
22
+ HTTP response returned by :meth:`backlog.base.BaseAPI.invoke_method`.
23
+
24
+ It provides the attributes of ``requests.Response`` that are commonly used with this library.
25
+ """
26
+
27
+ def __init__(self, status_code, headers, content, url):
28
+ self.status_code = status_code
29
+ self.headers = headers
30
+ self.content = content
31
+ self.url = url
32
+
33
+ @property
34
+ def ok(self):
35
+ return 200 <= self.status_code < 400
36
+
37
+ @property
38
+ def encoding(self):
39
+ _message = Message()
40
+ _message["Content-Type"] = self.headers.get("Content-Type", "")
41
+ return _message.get_content_charset() or "utf-8"
42
+
43
+ @property
44
+ def text(self):
45
+ return self.content.decode(self.encoding, errors="replace")
46
+
47
+ def json(self):
48
+ return json.loads(self.text)
49
+
50
+ def __repr__(self):
51
+ return "<Response [{}]>".format(self.status_code)
52
+
53
+
54
+ def _items(params):
55
+ """
56
+ Flatten a parameter dict into (key, value) string pairs, like requests does:
57
+ None values are skipped and list/tuple values become repeated keys.
58
+ """
59
+ pairs = []
60
+ for key, value in (params or {}).items():
61
+ values = value if isinstance(value, (list, tuple)) else [value]
62
+ for v in values:
63
+ if v is None:
64
+ continue
65
+ if isinstance(v, bytes):
66
+ v = v.decode()
67
+ pairs.append((str(key), str(v)))
68
+ return pairs
69
+
70
+
71
+ def encode_query(url, params):
72
+ pairs = _items(params)
73
+ if not pairs:
74
+ return url
75
+ return url + ("&" if "?" in url else "?") + urllib.parse.urlencode(pairs)
76
+
77
+
78
+ def encode_form(data):
79
+ pairs = _items(data)
80
+ if not pairs:
81
+ return None
82
+ return urllib.parse.urlencode(pairs).encode()
83
+
84
+
85
+ def encode_multipart(data, files):
86
+ """
87
+ Encode form fields and files as multipart/form-data.
88
+
89
+ :param files: dict of field name to ``(filename, fileobj_or_bytes[, content_type])``
90
+ :return: (body, content_type)
91
+ """
92
+ boundary = uuid.uuid4().hex
93
+ lines = []
94
+ for key, value in _items(data):
95
+ lines += [
96
+ "--{}".format(boundary).encode(),
97
+ 'Content-Disposition: form-data; name="{}"'.format(_quote(key)).encode(),
98
+ b"",
99
+ value.encode(),
100
+ ]
101
+ for key, spec in files.items():
102
+ filename, content = spec[0], spec[1]
103
+ content_type = (spec[2] if len(spec) > 2 else None) or "application/octet-stream"
104
+ if hasattr(content, "read"):
105
+ content = content.read()
106
+ if isinstance(content, str):
107
+ content = content.encode()
108
+ lines += [
109
+ "--{}".format(boundary).encode(),
110
+ 'Content-Disposition: form-data; name="{}"; filename="{}"'.format(_quote(key), _quote(filename)).encode(),
111
+ "Content-Type: {}".format(content_type).encode(),
112
+ b"",
113
+ content,
114
+ ]
115
+ lines += ["--{}--".format(boundary).encode(), b""]
116
+ return b"\r\n".join(lines), "multipart/form-data; boundary={}".format(boundary)
117
+
118
+
119
+ def _quote(value):
120
+ return str(value).replace("\\", "\\\\").replace('"', '\\"').replace("\r", "").replace("\n", "")
121
+
122
+
123
+ class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
124
+ """
125
+ Do not follow redirects. The Backlog API does not redirect, and following a redirect would
126
+ forward the API key header to wherever the redirect points.
127
+ """
128
+
129
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
130
+ return None
131
+
132
+
133
+ def _ssl_context():
134
+ """
135
+ Build the TLS context.
136
+
137
+ The system trust store is used as the standard library does (it also honors SSL_CERT_FILE and
138
+ SSL_CERT_DIR, so CAs added to the OS, such as those of corporate proxies, keep working).
139
+ CA bundles that requests users may rely on are added on top of it:
140
+
141
+ - the file in REQUESTS_CA_BUNDLE or CURL_CA_BUNDLE, if set;
142
+ - certifi's bundle, if certifi is installed (helps on systems without a usable trust store).
143
+ """
144
+ context = ssl.create_default_context()
145
+ for name in ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE"):
146
+ path = os.environ.get(name)
147
+ if not path:
148
+ continue
149
+ if os.path.isdir(path):
150
+ context.load_verify_locations(capath=path)
151
+ else:
152
+ context.load_verify_locations(cafile=path)
153
+ try:
154
+ import certifi
155
+ except ImportError:
156
+ pass
157
+ else:
158
+ context.load_verify_locations(cafile=certifi.where())
159
+ return context
160
+
161
+
162
+ _opener = None
163
+
164
+
165
+ def _get_opener():
166
+ global _opener
167
+ if _opener is None:
168
+ _opener = urllib.request.build_opener(
169
+ urllib.request.HTTPSHandler(context=_ssl_context()),
170
+ _NoRedirectHandler(),
171
+ )
172
+ return _opener
173
+
174
+
175
+ def _open(req, timeout):
176
+ """
177
+ Send the request. Returns (status, headers, body, url) for every HTTP status code.
178
+ """
179
+ try:
180
+ with _get_opener().open(req, timeout=timeout) as resp:
181
+ return resp.status, resp.headers, resp.read(), resp.url
182
+ except urllib.error.HTTPError as e:
183
+ with e:
184
+ return e.code, e.headers, e.read(), e.url
185
+
186
+
187
+ def request(method, url, params=None, data=None, headers=None, files=None, timeout=None):
188
+ """
189
+ Send an HTTP request and return a :class:`Response`.
190
+
191
+ Network errors raise ``OSError`` subclasses (``urllib.error.URLError``, ``TimeoutError``, ...).
192
+ """
193
+ _headers = dict(headers or {})
194
+ body = None
195
+ if files:
196
+ body, _headers["Content-Type"] = encode_multipart(data, files)
197
+ elif method.upper() != "GET":
198
+ body = encode_form(data)
199
+ if body is not None:
200
+ _headers["Content-Type"] = "application/x-www-form-urlencoded"
201
+
202
+ req = urllib.request.Request(encode_query(url, params), data=body, headers=_headers, method=method.upper())
203
+ status, response_headers, content, response_url = _open(req, timeout)
204
+ return Response(status, response_headers, content, response_url)
backlog/base.py CHANGED
@@ -1,7 +1,8 @@
1
1
  # coding: utf-8
2
2
 
3
3
  import re
4
- import requests
4
+ from backlog import _http
5
+ from backlog._http import Response # noqa: F401 (re-exported for type references)
5
6
  from backlog.wiki import Wiki
6
7
  from backlog.attachment import Attachment
7
8
  from backlog.project import Project
@@ -25,6 +26,18 @@ class BacklogError(Exception):
25
26
  pass
26
27
 
27
28
 
29
+ class BacklogConnectionError(BacklogError):
30
+ """
31
+ Raised when the request could not be sent or no response was received
32
+ (DNS failure, refused connection, TLS error, timeout, ...).
33
+ """
34
+ pass
35
+
36
+
37
+ # Keyword arguments accepted by invoke_method() in addition to its named parameters.
38
+ _SUPPORTED_KWARGS = frozenset(["timeout", "files"])
39
+
40
+
28
41
  class BaseAPI(object):
29
42
  def __init__(self, space, api_key=None, timeout=DEFAULT_TIMEOUT):
30
43
  """
@@ -39,11 +52,10 @@ class BaseAPI(object):
39
52
  self.api_key = api_key
40
53
  self.timeout = timeout
41
54
  self.base_url = BACKLOG_BASE_URL.format(space=space) + BACKLOG_URI_PREFIX
42
- self.api = None
43
55
  self._build_api()
44
56
 
45
57
  def _build_api(self):
46
- self.api = None
58
+ pass
47
59
 
48
60
  def _build_headers(self, headers=None):
49
61
  """
@@ -58,16 +70,24 @@ class BaseAPI(object):
58
70
 
59
71
  def invoke_method(self, method, uri, query_param=None, request_param=None, headers=None, **kwargs):
60
72
  """
61
- Delegate requests to get/post/delete method.
62
- Each method implements by any http client, default client is requests.
73
+ Delegate requests to get/post/delete/patch method.
63
74
 
64
- :param method: HTTP Method. supports GET/POST/DELETE
75
+ :param method: HTTP Method. supports GET/POST/DELETE/PATCH
65
76
  :param uri: API Endpoint. 'uri' expects a URI excluded '/api/v2/' prefix
66
77
  :param query_param: dict of URL parameter. this will be url-encoded string
67
78
  :param request_param: dict of body parameter. this will use when invoke post request
68
79
  :param headers: HTTP Header dict. Authentication headers are added automatically.
69
- :return: http response object
80
+ :param kwargs: ``timeout`` (seconds, overrides the client default) and ``files``
81
+ (dict of field name to ``(filename, fileobj_or_bytes[, content_type])``).
82
+ :return: :class:`backlog._http.Response`
70
83
  """
84
+ unsupported = set(kwargs) - _SUPPORTED_KWARGS
85
+ if unsupported:
86
+ raise TypeError(
87
+ "Unsupported keyword argument(s): {}. Since 0.9.0 python-backlog no longer uses requests, "
88
+ "so requests-specific options are not available.".format(", ".join(sorted(unsupported)))
89
+ )
90
+
71
91
  # Copy the arguments so that neither the caller's dicts nor any shared state are mutated.
72
92
  query_param = dict(query_param or {})
73
93
  request_param = dict(request_param or {})
@@ -116,11 +136,9 @@ class BaseAPI(object):
116
136
 
117
137
  class BacklogAPI(BaseAPI):
118
138
  """
119
- Using 'requests'
139
+ Backlog API client using the standard library HTTP client.
120
140
  """
121
141
  def _build_api(self):
122
- self.api = None
123
-
124
142
  self.wiki = Wiki(self)
125
143
  self.attachment = Attachment(self)
126
144
  self.project = Project(self)
@@ -131,32 +149,36 @@ class BacklogAPI(BaseAPI):
131
149
  self.custom_field = CustomField(self)
132
150
 
133
151
  @staticmethod
134
- def _raise_for_status(resp, ok):
135
- if ok(resp.status_code):
152
+ def _raise_for_status(resp):
153
+ if resp.status_code // 100 == 2:
136
154
  return resp
137
155
  raise BacklogError("Http response {status}: {message}".format(status=resp.status_code, message=resp.text))
138
156
 
157
+ def _send(self, method, uri, query_param, request_param, headers, timeout=None, files=None):
158
+ try:
159
+ resp = _http.request(
160
+ method,
161
+ self.base_url + uri,
162
+ params=query_param,
163
+ data=request_param,
164
+ headers=headers,
165
+ files=files,
166
+ timeout=timeout,
167
+ )
168
+ except OSError as e:
169
+ raise BacklogConnectionError(
170
+ "Failed to send {method} request to {url}: {error}".format(method=method.upper(), url=self.base_url + uri, error=e)
171
+ ) from e
172
+ return self._raise_for_status(resp)
173
+
139
174
  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)
175
+ return self._send("GET", uri, query_param, None, headers, **kwargs)
143
176
 
144
177
  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)
178
+ return self._send("POST", uri, query_param, request_param, headers, **kwargs)
148
179
 
149
180
  def delete(self, method, uri, query_param, request_param, headers, **kwargs):
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)
181
+ return self._send("DELETE", uri, query_param, request_param, headers, **kwargs)
153
182
 
154
183
  def patch(self, method, uri, query_param, request_param, headers, **kwargs):
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)
158
-
159
-
160
- class BacklogObject(object):
161
- def __init__(self, api):
162
- self.api = api
184
+ return self._send("PATCH", uri, query_param, request_param, headers, **kwargs)
backlog/project.py CHANGED
@@ -1,4 +1,6 @@
1
1
  # coding: utf-8
2
+
3
+ import warnings
2
4
  from typing import TypedDict, Literal, Optional, NewType
3
5
 
4
6
 
@@ -346,6 +348,11 @@ class Project(object):
346
348
  :param projectIdOrKey:
347
349
  :return:
348
350
  """
351
+ warnings.warn(
352
+ "Project.list_categories() is deprecated and will be removed in a future release; use BacklogAPI.category.list() instead.",
353
+ DeprecationWarning,
354
+ stacklevel=2,
355
+ )
349
356
  _uri = "projects/{project_id_or_key}/categories".format(
350
357
  project_id_or_key=projectIdOrKey,
351
358
  )
@@ -363,6 +370,11 @@ class Project(object):
363
370
  :param name: (str) Category name
364
371
  :return:
365
372
  """
373
+ warnings.warn(
374
+ "Project.add_category() is deprecated and will be removed in a future release; use BacklogAPI.category.add() instead.",
375
+ DeprecationWarning,
376
+ stacklevel=2,
377
+ )
366
378
  _uri = "projects/{project_id_or_key}/categories".format(
367
379
  project_id_or_key=projectIdOrKey,
368
380
  )
@@ -381,6 +393,11 @@ class Project(object):
381
393
  :param name: (str) will modified
382
394
  :return:
383
395
  """
396
+ warnings.warn(
397
+ "Project.update_category() is deprecated and will be removed in a future release; use BacklogAPI.category.update() instead.",
398
+ DeprecationWarning,
399
+ stacklevel=2,
400
+ )
384
401
  _uri = "projects/{project_id_or_key}/categories/{id}".format(
385
402
  project_id_or_key=projectIdOrKey,
386
403
  id=id
@@ -399,6 +416,11 @@ class Project(object):
399
416
  :param id:
400
417
  :return:
401
418
  """
419
+ warnings.warn(
420
+ "Project.delete_category() is deprecated and will be removed in a future release; use BacklogAPI.category.delete() instead.",
421
+ DeprecationWarning,
422
+ stacklevel=2,
423
+ )
402
424
  _uri = "projects/{project_id_or_key}/categories/{id}".format(
403
425
  project_id_or_key=projectIdOrKey,
404
426
  id=id
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-backlog
3
- Version: 0.8.1
3
+ Version: 0.9.0
4
4
  Summary: Backlog API v2 wrapper
5
5
  Author-email: hassaku63 <hassaku63@gmail.com>
6
6
  License-Expression: MIT
@@ -22,17 +22,17 @@ Classifier: Natural Language :: English
22
22
  Requires-Python: >=3.10
23
23
  Description-Content-Type: text/markdown
24
24
  License-File: LICENSE
25
- Requires-Dist: requests<3,>=2.33.0
26
25
  Provides-Extra: dev
27
26
  Requires-Dist: flake8>=7; extra == "dev"
28
27
  Requires-Dist: coverage>=7.6; extra == "dev"
29
- Requires-Dist: httpretty>=1.1.4; extra == "dev"
30
- Requires-Dist: tox>=4; extra == "dev"
31
28
  Dynamic: license-file
32
29
 
33
30
  # pbl
34
31
 
35
- A Python package wraps Backlog API V2
32
+ A Python package that wraps the [Backlog API v2](https://developer.nulab.com/docs/backlog/).
33
+
34
+ - Requires Python 3.10 or later.
35
+ - No runtime dependencies: only the Python standard library is used.
36
36
 
37
37
  ## Usage
38
38
 
@@ -58,11 +58,15 @@ def main():
58
58
  # Requests time out after 30 seconds by default (configurable with `timeout=`).
59
59
  api = BacklogAPI("your-space", "your-api-key")
60
60
 
61
+ # Categories
62
+ # https://developer.nulab.com/docs/backlog/api/2/get-category-list/
63
+ print(api.category.list("SampleProject"))
64
+
61
65
  """
62
66
  Project API
63
67
  """
64
68
  # list project users
65
- # https://developer.nulab-inc.com/ja/docs/backlog/api/2/get-project-list/
69
+ # https://developer.nulab.com/docs/backlog/api/2/get-project-list/
66
70
  print("# list project users")
67
71
  users = api.project.list_users("SampleProject")
68
72
  print(json.dumps(users, indent=2))
@@ -72,13 +76,13 @@ def main():
72
76
  Wiki API
73
77
  """
74
78
  # list wikis
75
- # https://developer.nulab-inc.com/ja/docs/backlog/api/2/get-wiki-page-list/
79
+ # https://developer.nulab.com/docs/backlog/api/2/get-wiki-page-list/
76
80
  print("# list wikis")
77
81
  wikis = api.wiki.list("SampleProject")
78
82
  print(json.dumps(wikis[0], indent=2))
79
83
 
80
84
  # get attachment
81
- # https://developer.nulab-inc.com/ja/docs/backlog/api/2/get-issue-attachment/
85
+ # https://developer.nulab.com/docs/backlog/api/2/get-issue-attachment/
82
86
  print("# get attachment")
83
87
  wiki = [w for w in api.wiki.list("SampleProject") if len(w["attachments"]) > 0][0]
84
88
  attachment = api.wiki.get_attachment(
@@ -92,10 +96,31 @@ if __name__ == "__main__":
92
96
  main()
93
97
  ```
94
98
 
99
+ ## Errors
100
+
101
+ - `backlog.base.BacklogError` is raised when the API returns a non-2xx response.
102
+ - `backlog.base.BacklogConnectionError` (a subclass of `BacklogError`) is raised when the request could not be sent or no response was received, for example on a timeout.
103
+
104
+ ## TLS certificates
105
+
106
+ HTTPS certificates are verified against the system trust store (it also honors `SSL_CERT_FILE` and `SSL_CERT_DIR`). If `certifi` is installed, or `REQUESTS_CA_BUNDLE` or `CURL_CA_BUNDLE` is set, those CA certificates are trusted as well.
107
+
108
+ ## Upgrading to 0.9
109
+
110
+ 0.9.0 removed the dependency on `requests`:
111
+
112
+ - `BaseAPI.invoke_method()` returns a `backlog._http.Response` instead of a `requests.Response`. It provides `status_code`, `ok`, `headers`, `content`, `text` and `json()`.
113
+ - `invoke_method()` only accepts the `timeout` and `files` keyword arguments. Requests-specific options such as `verify` or `proxies` raise `TypeError`. Proxies are still read from the `HTTP(S)_PROXY` and `NO_PROXY` environment variables.
114
+ - Network errors raise `BacklogConnectionError` instead of `requests.exceptions.*`.
115
+ - Redirects are not followed, so the API key is never sent to another host.
116
+ - `Project.list_categories()`, `add_category()`, `update_category()` and `delete_category()` are deprecated. Use `BacklogAPI.category` instead.
117
+
95
118
  ## See also
96
119
 
97
120
  Qiita https://qiita.com/hassaku_63/items/b9eb2a1c7ecd3c19507d
98
121
 
99
122
  ## Contact
100
123
 
124
+ Please open an issue on [GitHub](https://github.com/hassaku63/pbl/issues).
125
+
101
126
  Twitter: [hassaku_63](https://twitter.com/hassaku_63)
@@ -1,15 +1,16 @@
1
1
  backlog/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ backlog/_http.py,sha256=5mAhdQss-eOSgtMQalSATsZGMTxBccNVjMqMGKRxbc4,6385
2
3
  backlog/attachment.py,sha256=_Ex_ob7NC0ZMrt_5OM3MqLzIQrswx8SceKhOYNY5Wgs,872
3
- backlog/base.py,sha256=CnZ0LLGcHb8yJm6v4gXmVUcki_M1U3Wv8kNgZgyLGoc,6120
4
+ backlog/base.py,sha256=4hEjxogGKAcF3PQ4AetiC7hX-w1OmmE7o-BGP3tKD4Y,7081
4
5
  backlog/category.py,sha256=DQ06yhOCXxtDbPMZ1Ig1QD8n7Ht_ZFrp8PJzO18IeZk,2530
5
6
  backlog/custom_field.py,sha256=dJ-XnrYv0rlpLvPhIkZEgcyNjZB8GR3352Or8HUPKlc,12243
6
7
  backlog/git.py,sha256=mazfvwn1Ekga0eeigk22Xf3vhXEo-P5Irfyq6r_QbaA,3056
7
8
  backlog/issue.py,sha256=E7bhNLLQd1KkSEilGcrmKYlkfi_w8rVfRF6xI-PGEtc,6650
8
- backlog/project.py,sha256=SCNbxjpzKwK5eijN58su9jHnBnIT8XcLqU8kv_vuE3s,14348
9
+ backlog/project.py,sha256=9Wdy25M-dhDNuZ4_2iSUz1k3KkE5oLP8Ean7sVppCpU,15273
9
10
  backlog/user.py,sha256=kSEkInekQ8aGgYs5ygCdA5tsUYK3cxO9_Aay-sfaC2A,2890
10
11
  backlog/wiki.py,sha256=ThfWC-OlnHDHMYeARYJega92fd3QhZ817mhWF1OegT4,5943
11
- python_backlog-0.8.1.dist-info/licenses/LICENSE,sha256=1teWq4411yGkUz_9lD1V74mcHFRix2IqOAoFaghKYwo,1073
12
- python_backlog-0.8.1.dist-info/METADATA,sha256=4u-77IPN_BvwX0Nbm-ukxBIqKYgScy9Ut3U3ose3RcE,2809
13
- python_backlog-0.8.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
14
- python_backlog-0.8.1.dist-info/top_level.txt,sha256=qzHRU2P6F24ntkXGiIBl4Qf_AlV0BRebSc5tuLlcOIE,8
15
- python_backlog-0.8.1.dist-info/RECORD,,
12
+ python_backlog-0.9.0.dist-info/licenses/LICENSE,sha256=1teWq4411yGkUz_9lD1V74mcHFRix2IqOAoFaghKYwo,1073
13
+ python_backlog-0.9.0.dist-info/METADATA,sha256=YgM4IPdNaO49SM5n3Za7KuWOT_D2wxHxlqNCTC1OJpA,4344
14
+ python_backlog-0.9.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
15
+ python_backlog-0.9.0.dist-info/top_level.txt,sha256=qzHRU2P6F24ntkXGiIBl4Qf_AlV0BRebSc5tuLlcOIE,8
16
+ python_backlog-0.9.0.dist-info/RECORD,,