PageRenderTime 51ms CodeModel.GetById 20ms RepoModel.GetById 0ms app.codeStats 0ms

/pybitbucket/bitbucket.py

https://gitlab.com/ztane/python-bitbucket
Python | 327 lines | 242 code | 42 blank | 43 comment | 44 complexity | 9c2d6e8e0b6361e4a5d7ab6ab4b3bd03 MD5 | raw file
  1. # -*- coding: utf-8 -*-
  2. """
  3. Core classes for communicating with the Bitbucket API.
  4. Classes:
  5. - Enumeration: abstraction for a set of enumerated values
  6. - Client: abstraction over HTTP requests to Bitbucket API
  7. - BitbucketSpecialAction: an enum of special actions to be handled by children
  8. - BitbucketBase: parent class for Bitbucket resources
  9. - Bitbucket: root resource for the whole Bitbucket instance
  10. - BadRequestError: exception wrapping bad HTTP requests
  11. - ServerError: exception wrapping server errors
  12. """
  13. from json import loads
  14. from future.utils import python_2_unicode_compatible
  15. from functools import partial
  16. from requests import codes
  17. from requests.exceptions import HTTPError
  18. from uritemplate import expand
  19. from pybitbucket.auth import Anonymous
  20. from pybitbucket.entrypoints import entrypoints_json
  21. class Enumeration(object):
  22. @classmethod
  23. def values(cls):
  24. return [
  25. v
  26. for (k, v)
  27. in vars(cls).items()
  28. if not k.startswith('__')]
  29. @classmethod
  30. def expect_valid_value(cls, value):
  31. if value not in cls.values():
  32. raise NameError(
  33. "Value '{0}' is not in expected set [{1}]."
  34. .format(value, '|'.join(str(x) for x in cls.values())))
  35. def enum(type_name, **named_values):
  36. return type(type_name, (Enumeration,), named_values)
  37. class Client(object):
  38. bitbucket_types = set()
  39. @staticmethod
  40. def expect_ok(response, code=codes.ok):
  41. if code == response.status_code:
  42. return
  43. elif 400 == response.status_code:
  44. raise BadRequestError(response)
  45. elif 500 <= response.status_code:
  46. raise ServerError(response)
  47. else:
  48. response.raise_for_status()
  49. def convert_to_object(self, data):
  50. for t in Client.bitbucket_types:
  51. if t.is_type(data):
  52. return t(data, client=self)
  53. return data
  54. def remote_relationship(self, template, **keywords):
  55. url = expand(template, keywords)
  56. while url:
  57. response = self.session.get(url)
  58. self.expect_ok(response)
  59. json_data = response.json()
  60. if isinstance(json_data, list):
  61. for item in json_data:
  62. yield self.convert_to_object(item)
  63. url = None
  64. elif json_data.get('values'):
  65. for item in json_data['values']:
  66. yield self.convert_to_object(item)
  67. url = json_data.get('next')
  68. else:
  69. yield self.convert_to_object(json_data)
  70. url = None
  71. def get_bitbucket_url(self):
  72. return self.config.server_base_uri
  73. def get_username(self):
  74. return self.config.username
  75. def __init__(self, config=None):
  76. self.config = config or Anonymous()
  77. self.session = self.config.session
  78. BitbucketSpecialAction = enum(
  79. 'BitbucketSpecialAction',
  80. APPROVE='approve',
  81. DECLINE='decline',
  82. MERGE='merge',
  83. DIFF='diff')
  84. @python_2_unicode_compatible
  85. class BitbucketBase(object):
  86. id_attribute = 'id'
  87. @staticmethod
  88. def expect_bool(name, value):
  89. if not isinstance(value, bool):
  90. raise TypeError(
  91. "{0} is {1} instead of bool".format(name, type(value)))
  92. @staticmethod
  93. def expect_list(name, value):
  94. if not isinstance(value, (list, tuple)):
  95. raise TypeError(
  96. "{0} is {1} instead of list".format(name, type(value)))
  97. @staticmethod
  98. def links_from(data):
  99. links = {}
  100. # Bitbucket doesn't currently use underscore.
  101. # HAL JSON does use underscore.
  102. for link_name in ('links', '_links'):
  103. if data.get(link_name):
  104. links.update(data.get(link_name))
  105. for name, body in links.items():
  106. # Ignore quirky Bitbucket clone link
  107. if isinstance(body, dict):
  108. for href, url in body.items():
  109. if href == 'href':
  110. yield (name, url)
  111. @staticmethod
  112. def _has_v2_self_url(data, resource_type, id_attribute):
  113. if (
  114. (data.get('links') is None) or
  115. (data['links'].get('self') is None) or
  116. (data['links']['self'].get('href') is None) or
  117. (data.get(id_attribute) is None)):
  118. return False
  119. # Since the structure is right, assume it is v2.
  120. is_v2 = True
  121. url_path = data['links']['self']['href'].split('/')
  122. # Start looking from the end of the path.
  123. position = -1
  124. # Since repos have a slash in the full_name,
  125. # we have to match as many parts as we find (1 or 2).
  126. # And sometimes the id is an integer.
  127. for id_part in str(data[id_attribute]).split('/')[::-1]:
  128. is_v2 = is_v2 and (id_part == url_path[position])
  129. position -= 1
  130. # After matching the id_attribute,
  131. # the resource_type should be the preceding part of the path.
  132. is_v2 = (resource_type == url_path[position])
  133. return is_v2
  134. @classmethod
  135. def has_v2_self_url(cls, data):
  136. return cls._has_v2_self_url(data, cls.resource_type, cls.id_attribute)
  137. def add_remote_relationship_methods(self, data):
  138. for name, url in BitbucketBase.links_from(data):
  139. if (name not in BitbucketSpecialAction.values()):
  140. setattr(self, name, partial(
  141. self.client.remote_relationship,
  142. template=url))
  143. def add_inline_resources(self, data):
  144. for name, body in data.items():
  145. # author is not treated the same on all resources
  146. if name == 'author':
  147. # For Commits, author has a raw part and
  148. # a full User resource.
  149. if (body.get('raw') and body.get('user')):
  150. setattr(self, 'raw_author', body['raw'])
  151. setattr(self, 'author', self.client.convert_to_object(
  152. body['user']))
  153. # For PullRequests, author is just a User resource.
  154. else:
  155. setattr(self, name, self.client.convert_to_object(body))
  156. # If an attribute has a dictionary for a body,
  157. # then descend to check for embedded resources.
  158. elif isinstance(body, dict):
  159. setattr(self, name, self.client.convert_to_object(body))
  160. # If an attribute has a list for a body,
  161. # then descend into the array to check for embedded resources.
  162. elif isinstance(body, list):
  163. if (body and isinstance(body[0], dict)):
  164. setattr(self, name, [
  165. self.client.convert_to_object(i)
  166. for i in body])
  167. else:
  168. setattr(self, name, body)
  169. @classmethod
  170. def expand_link_urls(cls, **kwargs):
  171. """
  172. A helper method for 1.0 API resources that expands uri templates
  173. found in links into a fully navigable URL as would be found
  174. with a HAL-JSON resource.
  175. """
  176. links = loads(cls.links_json)
  177. return {'_links': {
  178. name: {'href': expand(template, kwargs)}
  179. for (name, template)
  180. in cls.links_from(links)}}
  181. @classmethod
  182. def get_link_template(cls, name):
  183. """
  184. A helper method for 1.0 API resources that gets the raw uri template
  185. for a specific link.
  186. """
  187. links = loads(cls.links_json)
  188. templates = [v for k, v in cls.links_from(links) if k == name]
  189. return templates[0]
  190. def __init__(self, data, client=Client()):
  191. self.data = data
  192. self.client = client
  193. self.__dict__.update(data)
  194. self.add_remote_relationship_methods(data)
  195. self.add_inline_resources(data)
  196. def delete(self):
  197. url = self.links['self']['href']
  198. response = self.client.session.delete(url)
  199. # Deletes the resource and returns 204 (No Content).
  200. Client.expect_ok(response, 204)
  201. return
  202. def put(self, json=None, **kwargs):
  203. url = self.links['self']['href']
  204. response = self.client.session.put(url, json=json, **kwargs)
  205. Client.expect_ok(response)
  206. return self.client.convert_to_object(response.json())
  207. @staticmethod
  208. def post(url, json=None, client=Client(), **kwargs):
  209. response = client.session.post(url, json=json, **kwargs)
  210. Client.expect_ok(response)
  211. return client.convert_to_object(response.json())
  212. def post_approval(self, template):
  213. response = self.client.session.post(template)
  214. Client.expect_ok(response)
  215. json_data = response.json()
  216. return json_data.get('approved')
  217. def delete_approval(self, template):
  218. response = self.client.session.delete(template)
  219. # Deletes the approval and returns 204 (No Content).
  220. Client.expect_ok(response, 204)
  221. return True
  222. def attributes(self):
  223. return list(self.data.keys())
  224. def relationships(self):
  225. return (
  226. list(self.data.get('_links', {}).keys()) +
  227. list(self.data.get('links', {}).keys()))
  228. def __repr__(self):
  229. return u'{name}({data})'.format(
  230. name=type(self).__name__,
  231. data=repr(self.data))
  232. def __str__(self):
  233. return u'{name} {id}:{data}'.format(
  234. name=type(self).__name__,
  235. id=self.id_attribute,
  236. data=getattr(self, self.id_attribute))
  237. class Bitbucket(BitbucketBase):
  238. def __init__(self, client=Client()):
  239. self.data = loads(entrypoints_json)
  240. self.client = client
  241. self.add_remote_relationship_methods(self.data)
  242. class BitbucketError(HTTPError):
  243. """Raise when Bitbucket has an HTTP error."""
  244. interpretation = "The client encountered an error."
  245. def format_message(self):
  246. return u'''Attempted to request {url}. \
  247. {interpretation} {code} - {text}\
  248. '''.format(
  249. url=self.url,
  250. interpretation=self.interpretation,
  251. code=self.code,
  252. text=self.text)
  253. def __init__(self, response):
  254. self.url = response.url
  255. self.code = response.status_code
  256. self.text = response.text
  257. try:
  258. # if the response is json,
  259. # then make it part of the exception structure
  260. json_data = response.json()
  261. json_error_message = json_data.get('error').get('message')
  262. self.error_message = json_error_message
  263. self.__dict__.update(json_data)
  264. except ValueError:
  265. pass
  266. super(BitbucketError, self).__init__(
  267. self.format_message())
  268. class BadRequestError(BitbucketError):
  269. """Raise when Bitbucket complains about a bad request."""
  270. interpretation = "Bitbucket considered it a bad request."
  271. def __init__(self, response):
  272. super(BadRequestError, self).__init__(response)
  273. class ServerError(BitbucketError):
  274. """Raise when Bitbucket complains about a server error."""
  275. interpretation = "The client encountered a server error."
  276. def __init__(self, response):
  277. super(ServerError, self).__init__(response)