├── .gitignore
├── COPYING
├── LICENSE
├── MANIFEST.in
├── README.rst
├── TYPES.rst
├── emots.py
├── endpointtest.py
├── histtest.py
├── hypchat.sublime-project
├── hypchat
├── __init__.py
├── __main__.py
├── requests.py
└── restobject.py
├── setup.py
└── tests
├── __init__.py
├── common.py
└── test_rate_limit.py
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyo
2 | *.pyc
3 | *.sublime-workspace
4 | build/
5 | dist/
6 | MANIFEST
7 | emots.html
8 |
--------------------------------------------------------------------------------
/COPYING:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 | Copyright (c) 2013, Riders Discount
3 | http://www.ridersdiscount.com/
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | COPYING
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include *.rst
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | =======
2 | HypChat
3 | =======
4 | A Python package for HipChat's `v2 JSON REST API`_. It's based on v2's navigability and self-declaration.
5 |
6 | .. _v2 JSON REST API: https://www.hipchat.com/docs/apiv2
7 |
8 | Installation
9 | ============
10 | HypChat can either be installed from PyPI_:
11 | ::
12 |
13 | pip install hypchat
14 |
15 | Or from source_:
16 | ::
17 |
18 | python setup.py install
19 |
20 | .. _PyPI: https://pypi.python.org/pypi/hypchat/
21 | .. _source: https://github.com/RidersDiscountCom/HypChat
22 |
23 | Concepts
24 | ========
25 |
26 | There are two basic types in HypChat: ``Linker`` and ``RestObject``. They are not meant to be instantiated directly but instead created as references from other objects.
27 |
28 | Linker
29 | ------
30 | A simple callable that represents an unfollowed reference.
31 |
32 | ``l.url``
33 | The URL this object points to
34 |
35 | ``l()``
36 | Calling a ``Linker`` will perform the request and return a ``RestObject``
37 |
38 | RestObject
39 | ----------
40 | A subclass of ``dict``, contains additional functionality for links and actions.
41 |
42 | Links
43 | ~~~~~
44 | As part of the v2 API, all objects have a ``links`` property with references to other objects. This is used to create ``Linker`` objects as attributes.
45 |
46 | For example, all objects have a link called ``self``. This may be referenced as:
47 | ::
48 |
49 | obj.self
50 |
51 | And the request performed by calling it:
52 | ::
53 |
54 | obj.self()
55 |
56 | .. _expand:
57 |
58 | If `Title Expansion`_ is desired, just past a list of things to be expanded as the ``expand`` keyword argument.
59 |
60 | .. _Title Expansion: https://www.hipchat.com/docs/apiv2/expansion
61 |
62 | Other Actions
63 | ~~~~~~~~~~~~~
64 |
65 | Many of the v2 types define additional types, eg Rooms have methods for messaging, setting the topic, getting the history, and inviting users to the room. These are implemented as methods of subclasses. The complete listing is in the `Type List`_.
66 |
67 | Timezone Handling
68 | -----------------
69 | HypChat uses aware ``datetime`` objects throughout by the ``dateutil`` module. However, the HipChat API universally uses UTC.
70 |
71 | For methods that take a ``datetime``, if a naive object is given, it will be assumed to be in UTC. If this is not what you mean, ``dateutil.tz`` has a wonderful selection of timezones_ available.
72 |
73 | .. _timezones: http://labix.org/python-dateutil#head-587bd3efc48f897f55c179abc520a34330ee0a62
74 |
75 | Usage
76 | =====
77 |
78 | First, create a HypChat object with the token
79 |
80 | ::
81 |
82 | hc = HypChat("mytoken")
83 |
84 | If you use Hipchat Server
85 |
86 | ::
87 |
88 | hc = HypChat("mytoken", endpoint="https://hipchat.example.com")
89 |
90 | There are several root links:
91 |
92 | ::
93 |
94 | rooms = hc.rooms()
95 | users = hc.users()
96 | emots = hc.emoticons()
97 | caps = hc.capabilities()
98 |
99 | In addition, the HypChat object has methods for creating objects and directly referencing the basic types.
100 |
101 | For example, you might do:
102 |
103 | ::
104 |
105 | for room in (r for r in hipchat.rooms(expand='items') if r['last_active'] < datetime.datetime(2013, 12, 1)):
106 | room.owner.message("Your room is dead; maybe archive it")
107 |
108 | Since ``room.owner`` is a User stub, not just a generic object. (The Room objects are not stubs, since the ``expand`` keyword is used).
109 |
110 | Downloading history is as easy as:
111 |
112 | ::
113 |
114 | list(HypChat(token).get_room(id).history(datetime.datetime.utcnow()).contents())
115 |
116 | Note that this may eat up many requests for large rooms.
117 |
118 | Navigation
119 | ----------
120 | Any time an object is referenced in a value (eg ``room['owner']``), a stub of that object is created, and the full object may be found with ``.self()``. Stubs contain the ID of the object, the name (if applicable), and any links that object has-including ``self``. This can be avoided by using the expand_ keyword.
121 |
122 | Collections-such as ``rooms``, ``users``, and ``emots`` above-all have an ``'items'`` key containing their list of things. In addition, the ``.contents()`` method will generate all of the items, handling pagination. As usual, object
123 |
124 | Console
125 | -------
126 | If you call ``python -m hypchat``, a interactive prompt (using IPython_ if available) will appear. The environment will contain ``hipchat``, an instance of the ``HypChat`` object. The token is pulled from ``~/.hypchat``, ``/etc/hypchat``, or the environment variable ``HIPCHAT_TOKEN``.
127 |
128 | .. _IPython: http://ipython.org/
129 |
130 | Type List
131 | =========
132 |
133 | See `TYPES.rst`_
134 |
135 | .. _TYPES.rst: https://github.com/RidersDiscountCom/HypChat/blob/master/TYPES.rst
136 |
137 | TODO List
138 | =========
139 | * API Links
140 |
--------------------------------------------------------------------------------
/TYPES.rst:
--------------------------------------------------------------------------------
1 | ======================
2 | HypChat Type Reference
3 | ======================
4 |
5 | HypChat
6 | -------
7 | As the root object, this is mostly full of "singletons" and shortcut methods.
8 |
9 | Links
10 | ~~~~~
11 | ``capabilities``
12 | The `capabilities descriptor`_ for HipChat
13 |
14 | ``emoticons``
15 | The `Emoticons Collection`_
16 |
17 | ``rooms``
18 | The `Rooms Collection`_
19 |
20 | ``users``
21 | The `Users Collection`_
22 |
23 | .. _capabilities descriptor: https://www.hipchat.com/docs/apiv2/method/get_capabilities
24 |
25 | Methods
26 | ~~~~~~~
27 | ``create_user()``
28 | Creates a new User_ object and returns the response.
29 |
30 | ``get_user()``
31 | Gets a User_ directly without having to navigate
32 |
33 | ``create_room()``
34 | Creates a new Room_ object and returns the response.
35 |
36 | ``get_room()``
37 | Gets a Room_ directly without having to navigate
38 |
39 | ``get_emoticon()``
40 | Gets a Emoticon_ directly without having to navigate
41 |
42 | REST Objects
43 | ------------
44 | All objects, collections, rooms, webhooks, etc have these things.
45 |
46 | Links
47 | ~~~~~
48 | ``self``
49 | A link to this object
50 |
51 | Values
52 | ~~~~~~
53 | ``id``
54 | The numeric ID of this object (except collections and webhooks)
55 |
56 | Methods
57 | ~~~~~~~
58 | ``delete()``
59 | Attempts to delete the object (with HTTP ``DELETE``)
60 |
61 | ``save()``
62 | Attempts to save the object (with HTTP ``PUT``)
63 |
64 | All Collections
65 | ---------------
66 | All collections share this interface. Specific collection objects are "paged": no single object contains all items.
67 |
68 | Links
69 | ~~~~~
70 | ``next``
71 | (Optional) The next page in this collection
72 | ``prev``
73 | (Optional) The previous page in this collection
74 |
75 | Values
76 | ~~~~~~
77 | ``items``
78 | A list of the things for this 'page' of the collection
79 |
80 | ``maxResults``
81 | The maximum number of items that could be in this page
82 |
83 | ``startIndex``
84 | The index of the first item in this page, starting at 0
85 |
86 | Methods
87 | ~~~~~~~
88 | ``contents()``
89 | A generator that produces all items, navigating pagination in the process
90 |
91 | Rooms Collection
92 | ----------------
93 | In addition to the things defined in `All Collections`_, the Rooms Collection has the below.
94 |
95 | Methods
96 | ~~~~~~~
97 | ``create()``
98 | Creates a new Room_
99 |
100 | Room
101 | ----
102 | Representing a single chat room.
103 |
104 | Links
105 | ~~~~~
106 | ``webhooks``
107 | The `Webhooks Collection`_ for this room
108 |
109 | ``members``
110 | (Optional) For private rooms only; the `Members Collection`_ for this room
111 |
112 | Methods
113 | ~~~~~~~
114 | ``message()``
115 | Currently a pointer to ``notification()``
116 |
117 | ``notification()``
118 | Sends a message to a room
119 |
120 | ``topic()``
121 | Sets the topic
122 |
123 | ``history()``
124 | Grabs a collection of the history. Note that the items of this collection are not full REST objects,
125 | just dictionaries. See `All Collections`_ for the interface.
126 |
127 | ``invite()``
128 | Invite a user to this room
129 |
130 | Values
131 | ~~~~~~
132 | ``name``
133 | Display name
134 |
135 | ``created``
136 | When the room was created
137 |
138 | ``guest_access_url``
139 | The URL to give for guest access, if enabled
140 |
141 | ``is_archived``
142 | ``True`` if this room is archived, ``False`` otherwise
143 |
144 | ``last_active``
145 | When the room last had activity
146 |
147 | ``owner``
148 | A reference to the owning User_
149 |
150 | ``participants``
151 | A list of User_ stubs currently in the room
152 |
153 | ``privacy``
154 | One of ``'public'`` or ``'private'``
155 |
156 | ``topic``
157 | The current topic
158 |
159 | ``xmpp_jid``
160 | The XMPP (Jabber) ID
161 |
162 | Webhooks Collection
163 | -------------------
164 | In addition to the those in `All Collections`_, the Webhooks Collection has the below.
165 |
166 | Methods
167 | ~~~~~~~
168 | ``create()``
169 | Create a new Webhook_
170 |
171 | Webhook
172 | -------
173 | Unlike most REST Objects, Webhooks don't have an ID. Their stub is also much more extensive
174 |
175 | Fields
176 | ~~~~~~
177 | ``url``
178 | (Stubbed) The URL to ``POST`` to
179 |
180 | ``event``
181 | (Stubbed) The event to call this hook on, one of ``'room_message'``, ``'room_notification'``, ``'room_exit'``, ``'room_enter'``, ``'room_topic_change'``
182 |
183 | ``pattern``
184 | (Stubbed) When ``event`` is ``'room_message'``, a regular expression to match against the message
185 |
186 | ``name``
187 | (Stubbed) A human label for this hook
188 |
189 | ``room``
190 | The Room_ this webhook is for
191 |
192 | ``creator``
193 | The User_ that created this webhook
194 |
195 | ``created``
196 | When this webhook was created
197 |
198 | Members Collection
199 | ------------------
200 | In addition to the those in `All Collections`_, the Members Collection has the below.
201 |
202 | Methods
203 | ~~~~~~~
204 | ``add()``
205 | Add a User_ to the list of members
206 |
207 | ``remove()``
208 | Remove a User_ from the list of members
209 |
210 | Users Collection
211 | ----------------
212 | In addition to the things defined in `All Collections`_, the Users Collection has the below.
213 |
214 | Methods
215 | ~~~~~~~
216 | ``create()``
217 | Creates a new User_
218 |
219 | User
220 | ----
221 |
222 | Methods
223 | ~~~~~~~
224 | ``message()``
225 | Sends a private message to the given user
226 |
227 | Values
228 | ~~~~~~
229 | ``name``
230 | Display name
231 |
232 | ``xmpp_jid``
233 | The XMPP (Jabber) ID of the user
234 |
235 | ``is_deleted``
236 | ``True`` if the user has been deleted
237 |
238 | ``last_active``
239 | The last time the user was active
240 |
241 | ``title``
242 | The person's company title
243 |
244 | ``presence``
245 | A ``dict`` of the values:
246 |
247 | ``status``
248 | A status message, or ``None``
249 |
250 | ``idle``
251 | The number of seconds the user has been idle, or ``None``
252 |
253 | ``show``
254 | The status category, one of: ``'away'``, ``'chat'``, ``'dnd'``, ``'xa'``, or ``None``
255 |
256 | ``is_online``
257 | ``True`` if the user is online
258 |
259 | ``created``
260 | When the User was created
261 |
262 | ``mention_name``
263 | User's @mention name
264 |
265 | ``is_group_admin``
266 | ``True`` if the user is an admin of the group/company/etc
267 |
268 | ``timezone``
269 | The user's timezone
270 |
271 | ``email``
272 | The user's email
273 |
274 | ``photo_url``
275 | The user's URL as a string.
276 |
277 | Emoticons Collection
278 | --------------------
279 | Defines nothing above `All Collections`_
280 |
281 | Emoticon
282 | --------
283 |
284 | Fields
285 | ~~~~~~
286 | ``shortcut``
287 | (Stubbed) The name used to invoke the emoticon, as in "(shortcut)"
288 |
289 | ``url``
290 | (Stubbed) The image file for this emoticon
291 |
292 | ``creator``
293 | The User_ that created this emoticon
294 |
295 | ``width``
296 | The width of the image, in pixels
297 |
298 | ``height``
299 | The height of the image, in pixels
300 |
301 | ``audio_path``
302 | (Optional) An audio file that should be played at the same time
303 |
--------------------------------------------------------------------------------
/emots.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python
2 | """
3 | Writes out emots.html, and index of all emoticons available to you.
4 | """
5 |
6 | import os, os.path
7 | import ConfigParser
8 | import sys
9 | from hypchat import *
10 |
11 | config = ConfigParser.ConfigParser()
12 | config.read([os.path.expanduser('~/.hypchat'), '/etc/hypchat'])
13 | AUTH_TOKEN = config.get('HipChat', 'token')
14 |
15 | hipchat = HypChat(AUTH_TOKEN)
16 |
17 | with open('emots.html', 'w') as html:
18 | html.write("""
19 |
20 |
21 |
22 | HipChat Emoticons
23 |
24 |
25 | """)
26 | for emot in hipchat.emoticons().contents():
27 | html.write("""
28 |
29 | """.format(**emot))
30 |
31 | html.write("""
32 |
33 | """)
--------------------------------------------------------------------------------
/endpointtest.py:
--------------------------------------------------------------------------------
1 | import os
2 | import ConfigParser
3 | import sys
4 | from hypchat import *
5 |
6 | AUTH_TOKEN = None
7 | ENDPOINT = None
8 | config = ConfigParser.ConfigParser()
9 | config.read([os.path.expanduser('~/.hypchat'), '/etc/hypchat'])
10 | if config.has_section('HipChat'):
11 | AUTH_TOKEN = config.get('HipChat', 'token')
12 | ENDPOINT = config.get('HipChat', 'endpoint')
13 |
14 | if 'HIPCHAT_TOKEN' in os.environ:
15 | AUTH_TOKEN = os.environ['HIPCHAT_TOKEN']
16 |
17 | if 'HIPCHAT_ENDPOINT' in os.environ:
18 | ENDPOINT = os.environ['HIPCHAT_ENDPOINT']
19 |
20 | if ENDPOINT:
21 | hipchat = HypChat(AUTH_TOKEN, ENDPOINT)
22 | else:
23 | hipchat = HypChat(AUTH_TOKEN)
24 |
25 | import datetime
26 |
27 | room = hipchat.rooms()['items'][0]
28 | print(room['name'])
29 |
--------------------------------------------------------------------------------
/histtest.py:
--------------------------------------------------------------------------------
1 | import os, os.path
2 | import ConfigParser
3 | import sys
4 | from hypchat import *
5 |
6 | config = ConfigParser.ConfigParser()
7 | config.read([os.path.expanduser('~/.hypchat'), '/etc/hypchat'])
8 | AUTH_TOKEN = config.get('HipChat', 'token')
9 |
10 | if 'HIPCHAT_TOKEN' in os.environ:
11 | AUTH_TOKEN = os.environ['HIPCHAT_TOKEN']
12 |
13 | hipchat = HypChat(AUTH_TOKEN)
14 |
15 | import datetime
16 |
17 | room = hipchat.rooms()['items'][0]
18 | print room['name']
19 | hist = room.history(datetime.datetime.utcnow(), maxResults=500)
20 | fullhist = []
21 | for item in hist.contents():
22 | print item['message']
--------------------------------------------------------------------------------
/hypchat.sublime-project:
--------------------------------------------------------------------------------
1 | {
2 | "folders":
3 | [
4 | {
5 | "follow_symlinks": true,
6 | "path": "."
7 | }
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/hypchat/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import, division
2 | import json
3 | import time
4 | import warnings
5 | import sys
6 | import os
7 | import datetime
8 | import six
9 | from .requests import Requests, BearerAuth, HttpTooManyRequests
10 | from .restobject import Linker
11 |
12 |
13 | class RateLimitWarning(Warning):
14 | """
15 | This token has been rate limited. Waiting for the next reset.
16 | """
17 |
18 |
19 | def jsonify(obj):
20 | if isinstance(obj, datetime.datetime):
21 | return obj.isoformat()
22 | elif isinstance(obj, set):
23 | return list(obj)
24 | else:
25 | raise TypeError("Can't JSONify objects of type %s" % type(obj).__name__)
26 |
27 |
28 | class _requests(Requests):
29 | def __init__(self, *p, **kw):
30 | super(_requests, self).__init__(*p, **kw)
31 | self.rl_remaining = 99999
32 | self.rl_reset = 0
33 | self.dump_reqs = '__HYPCHAT_DEBUG_REQUESTS__' in os.environ
34 |
35 | @staticmethod
36 | def _data(data, kwargs):
37 | if isinstance(data, six.string_types):
38 | return data
39 | elif data is not None:
40 | kwargs.setdefault('headers', {})['Content-Type'] = 'application/json'
41 | rv = json.dumps(data, default=jsonify)
42 | return rv
43 |
44 | def _rl_sleep(self, until):
45 | t = until - time.time()
46 | if t > 0:
47 | warnings.warn("HipChat has been rate limited; Waiting %0.1fs for the next reset." % t, RateLimitWarning)
48 | time.sleep(t)
49 |
50 | def request(self, method, url, **kwargs):
51 | if self.dump_reqs:
52 | print >> sys.stderr, "REQUEST", method, url
53 | while True:
54 | try:
55 | if self.rl_remaining <= 0:
56 | # We're out of requests, chill
57 | self._rl_sleep(self.rl_reset)
58 | resp = super(_requests, self).request(method, url, **kwargs)
59 | except HttpTooManyRequests as e:
60 | self.rl_remaining = int(e.response.headers['x-ratelimit-remaining'])
61 | if not self.rl_remaining:
62 | self.rl_reset = float(e.response.headers['x-ratelimit-reset'])
63 | continue # Try the request again
64 | else:
65 | raise
66 | else:
67 | self.rl_remaining = int(resp.headers['x-ratelimit-remaining'])
68 | self.rl_reset = float(resp.headers['x-ratelimit-reset'])
69 | return resp
70 |
71 | def post(self, url, data=None, **kwargs):
72 | data = self._data(data, kwargs)
73 | return super(_requests, self).post(url, data=data, **kwargs)
74 |
75 | def patch(self, url, data=None, **kwargs):
76 | data = self._data(data, kwargs)
77 | return super(_requests, self).patch(url, data=data, **kwargs)
78 |
79 | def put(self, url, data=None, **kwargs):
80 | data = self._data(data, kwargs)
81 | return super(_requests, self).put(url, data=data, **kwargs)
82 |
83 |
84 | __all__ = ('HypChat',)
85 |
86 |
87 | class HypChat(object):
88 | def __init__(self, token, endpoint='https://api.hipchat.com', verify=True):
89 | self._requests = _requests(auth=BearerAuth(token), verify=verify)
90 | self.capabilities = Linker('{0}/v2/capabilities'.format(endpoint), _requests=self._requests)
91 | self.emoticons = Linker('{0}/v2/emoticon'.format(endpoint), _requests=self._requests)
92 | self.rooms = Linker('{0}/v2/room'.format(endpoint), _requests=self._requests)
93 | self.users_url = '{0}/v2/user'.format(endpoint)
94 | self.endpoint = endpoint
95 |
96 | def users(self, **ops):
97 | """users([guests=bool], [deleted=bool]) -> UserCollection
98 |
99 | Returns a collection of users, with the following keyword options:
100 | * guests: If True, return active guests
101 | * deleted: If True, return deleted users
102 | """
103 | params = {}
104 | if ops.get('guests', False):
105 | params['include-guests'] = 'true'
106 | if ops.get('deleted', False):
107 | params['include-deleted'] = 'true'
108 | if 'expand' in ops:
109 | params['expand'] = ops['expand']
110 | resp = self._requests.get(self.users_url, params=params)
111 | return Linker._obj_from_text(resp.text, self._requests)
112 |
113 |
114 | def fromurl(self, url, **kwargs):
115 | return Linker(url, _requests=self._requests)(**kwargs)
116 |
117 | def create_room(self, name, owner=Ellipsis, privacy='public', guest_access=True):
118 | """
119 | Creates a new room.
120 | """
121 | data = {
122 | 'name': name,
123 | 'privacy': privacy,
124 | 'guest_access': guest_access,
125 | }
126 | if owner is not Ellipsis:
127 | if owner is None:
128 | data['owner_user_id'] = owner
129 | else:
130 | data['owner_user_id'] = owner['id']
131 | resp = self._requests.post(self.rooms.url, data=data)
132 | return Linker._obj_from_text(resp.text, self._requests)
133 |
134 | def create_user(self, name, email, title='', mention_name='', is_group_admin=False, timezone='UTC', password=''):
135 | """
136 | Creates a new user.
137 | """
138 | data = {
139 | 'name': name,
140 | 'email': email,
141 | 'title': title,
142 | 'mention_name': mention_name,
143 | 'is_group_admin': is_group_admin,
144 | 'timezone': timezone, # TODO: Support timezone objects
145 | 'password': password,
146 | }
147 | resp = self._requests.post(self.users_url, data=data)
148 | return Linker._obj_from_text(resp.text, self._requests)
149 |
150 | def get_room(self, id_or_name, **kwargs):
151 | return self.fromurl('{0}/v2/room/{1}'.format(self.endpoint, id_or_name), **kwargs)
152 |
153 | def get_user(self, id_or_email, **kwargs):
154 | return self.fromurl('{0}/v2/user/{1}'.format(self.endpoint, id_or_email), **kwargs)
155 |
156 | def get_emoticon(self, id_or_shortcut, **kwargs):
157 | return self.fromurl('{0}/v2/emoticon/{1}'.format(self.endpoint, id_or_shortcut), **kwargs)
158 |
--------------------------------------------------------------------------------
/hypchat/__main__.py:
--------------------------------------------------------------------------------
1 | from . import *
2 | import os, os.path
3 | import ConfigParser
4 | import sys
5 |
6 | config = ConfigParser.ConfigParser()
7 | config.read([os.path.expanduser('~/.hypchat'), '/etc/hypchat'])
8 | if config.has_section('HipChat'):
9 | AUTH_TOKEN = config.get('HipChat', 'token')
10 |
11 | elif 'HIPCHAT_TOKEN' in os.environ:
12 | AUTH_TOKEN = os.environ['HIPCHAT_TOKEN']
13 |
14 | else:
15 | print('Authorization token not detected! The token is pulled from ' \
16 | '~/.hypchat, /etc/hypchat, or the environment variable HIPCHAT_TOKEN.')
17 | sys.exit(1)
18 |
19 | ENDPOINT = None
20 | if config.has_section('HipChat'):
21 | ENDPOINT = config.get('HipChat', 'endpoint')
22 |
23 | elif 'HIPCHAT_ENDPOINT' in os.environ:
24 | ENDPOINT = os.environ['HIPCHAT_ENDPOINT']
25 |
26 | if ENDPOINT:
27 | hipchat = HypChat(AUTH_TOKEN, ENDPOINT)
28 | else:
29 | hipchat = HypChat(AUTH_TOKEN)
30 |
31 | capabilities = hipchat.capabilities
32 | emoticons = hipchat.emoticons
33 | rooms = hipchat.rooms
34 | users = hipchat.users
35 | endpoint = hipchat.endpoint
36 |
37 | try:
38 | import IPython
39 |
40 | IPython.embed()
41 | except ImportError:
42 | import code
43 |
44 | code.interact(local=locals())
45 |
--------------------------------------------------------------------------------
/hypchat/requests.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import, division
2 | import requests
3 |
4 |
5 | class BearerAuth(requests.auth.AuthBase):
6 | def __init__(self, token):
7 | self.token = token
8 |
9 | def __call__(self, r):
10 | r.headers['Authorization'] = "Bearer %s" % self.token
11 | return r
12 |
13 |
14 | def req(method, url, params=None, data=None):
15 | c = getattr(requests, method.lower())
16 | kwargs = {}
17 | if params is not None:
18 | kwargs['params'] = params
19 | if data is not None:
20 | kwargs['headers'] = {'content-type': 'application/json'}
21 | kwargs['data'] = json.dumps(data)
22 | return c(url, auth=BearerAuth(AUTH_TOKEN), **kwargs)
23 |
24 |
25 | class HttpClientError(requests.exceptions.HTTPError):
26 | """The 4xx class of status code is intended for cases in which the client seems to have erred.
27 | Except when responding to a HEAD request, the server SHOULD include an entity containing an
28 | explanation of the error situation, and whether it is a temporary or permanent condition. These
29 | status codes are applicable to any request method. User agents SHOULD display any included
30 | entity to the user.
31 | """
32 |
33 |
34 | _http_errors = {}
35 |
36 |
37 | class HttpBadRequest(HttpClientError):
38 | """400 Bad Request
39 |
40 | The request could not be understood by the server due to malformed syntax. The client SHOULD
41 | NOT repeat the request without modifications.
42 | """
43 |
44 |
45 | _http_errors[400] = HttpBadRequest
46 |
47 |
48 | class HttpUnauthorized(HttpClientError):
49 | """401 Unauthorized
50 |
51 | The request requires user authentication. The response MUST include a WWW-Authenticate header
52 | field (section 14.47) containing a challenge applicable to the requested resource. The client
53 | MAY repeat the request with a suitable Authorization header field (section 14.8). If the
54 | request already included Authorization credentials, then the 401 response indicates that
55 | authorization has been refused for those credentials. If the 401 response contains the same
56 | challenge as the prior response, and the user agent has already attempted authentication at
57 | least once, then the user SHOULD be presented the entity that was given in the response, since
58 | that entity might include relevant diagnostic information. HTTP access authentication is
59 | explained in "HTTP Authentication: Basic and Digest Access Authentication".
60 | """
61 |
62 |
63 | _http_errors[401] = HttpUnauthorized
64 |
65 |
66 | class HttpPaymentRequired(HttpClientError):
67 | """402 Payment Required
68 |
69 | This code is reserved for future use.
70 | """
71 |
72 |
73 | _http_errors[402] = HttpPaymentRequired
74 |
75 |
76 | class HttpForbidden(HttpClientError):
77 | """403 Forbidden
78 |
79 | The server understood the request, but is refusing to fulfill it. Authorization will not help
80 | and the request SHOULD NOT be repeated. If the request method was not HEAD and the server
81 | wishes to make public why the request has not been fulfilled, it SHOULD describe the reason for
82 | the refusal in the entity. If the server does not wish to make this information available to
83 | the client, the status code 404 (Not Found) can be used instead.
84 | """
85 |
86 |
87 | _http_errors[403] = HttpForbidden
88 |
89 |
90 | class HttpNotFound(HttpClientError):
91 | """404 Not Found
92 |
93 | The server has not found anything matching the Request-URI. No indication is given of whether
94 | the condition is temporary or permanent. The 410 (Gone) status code SHOULD be used if the
95 | server knows, through some internally configurable mechanism, that an old resource is
96 | permanently unavailable and has no forwarding address. This status code is commonly used when
97 | the server does not wish to reveal exactly why the request has been refused, or when no other
98 | response is applicable.
99 | """
100 |
101 |
102 | _http_errors[404] = HttpNotFound
103 |
104 |
105 | class HttpMethodNotAllowed(HttpClientError):
106 | """405 Method Not Allowed
107 |
108 | The method specified in the Request-Line is not allowed for the resource identified by the
109 | Request-URI. The response MUST include an Allow header containing a list of valid methods for
110 | the requested resource.
111 | """
112 |
113 |
114 | _http_errors[405] = HttpMethodNotAllowed
115 |
116 |
117 | class HttpNotAcceptable(HttpClientError):
118 | """The resource identified by the request is only capable of generating response entities which
119 | have content characteristics not acceptable according to the accept headers sent in the request.
120 |
121 | Unless it was a HEAD request, the response SHOULD include an entity containing a list of
122 | available entity characteristics and location(s) from which the user or user agent can choose
123 | the one most appropriate. The entity format is specified by the media type given in the
124 | Content-Type header field. Depending upon the format and the capabilities of the user agent,
125 | selection of the most appropriate choice MAY be performed automatically. However, this
126 | specification does not define any standard for such automatic selection.
127 |
128 | Note: HTTP/1.1 servers are allowed to return responses which are not acceptable according
129 | to the accept headers sent in the request. In some cases, this may even be preferable to
130 | sending a 406 response. User agents are encouraged to inspect the headers of an incoming
131 | response to determine if it is acceptable.
132 |
133 | If the response could be unacceptable, a user agent SHOULD temporarily stop receipt of more
134 | data and query the user for a decision on further actions.
135 | """
136 |
137 |
138 | _http_errors[406] = HttpNotAcceptable
139 |
140 |
141 | class HttpProxyAuthenticationRequired(HttpClientError):
142 | """407 Proxy Authentication Required
143 |
144 | This code is similar to 401 (Unauthorized), but indicates that the client must first
145 | authenticate itself with the proxy. The proxy MUST return a Proxy-Authenticate header field
146 | (section 14.33) containing a challenge applicable to the proxy for the requested resource. The
147 | client MAY repeat the request with a suitable Proxy-Authorization header field (section 14.34).
148 | HTTP access authentication is explained in "HTTP Authentication: Basic and Digest Access
149 | Authentication".
150 | """
151 |
152 |
153 | _http_errors[407] = HttpProxyAuthenticationRequired
154 |
155 |
156 | class HttpRequestTimeout(HttpClientError):
157 | """408 Request Timeout
158 |
159 | The client did not produce a request within the time that the server was prepared to wait. The
160 | client MAY repeat the request without modifications at any later time.
161 | """
162 |
163 |
164 | _http_errors[408] = HttpRequestTimeout
165 |
166 |
167 | class HttpConflict(HttpClientError):
168 | """409 Conflict
169 |
170 | The request could not be completed due to a conflict with the current state of the resource.
171 | This code is only allowed in situations where it is expected that the user might be able to
172 | resolve the conflict and resubmit the request. The response body SHOULD include enough
173 | information for the user to recognize the source of the conflict. Ideally, the response entity
174 | would include enough information for the user or user agent to fix the problem; however, that
175 | might not be possible and is not required.
176 |
177 | Conflicts are most likely to occur in response to a PUT request. For example, if versioning
178 | were being used and the entity being PUT included changes to a resource which conflict with
179 | those made by an earlier (third-party) request, the server might use the 409 response to
180 | indicate that it can't complete the request. In this case, the response entity would likely
181 | contain a list of the differences between the two versions in a format defined by the response
182 | Content-Type.
183 | """
184 |
185 |
186 | _http_errors[409] = HttpConflict
187 |
188 |
189 | class HttpGone(HttpClientError):
190 | """410 Gone
191 |
192 | The requested resource is no longer available at the server and no forwarding address is known.
193 | This condition is expected to be considered permanent. Clients with link editing capabilities
194 | SHOULD delete references to the Request-URI after user approval. If the server does not know,
195 | or has no facility to determine, whether or not the condition is permanent, the status code 404
196 | (Not Found) SHOULD be used instead. This response is cacheable unless indicated otherwise.
197 |
198 | The 410 response is primarily intended to assist the task of web maintenance by notifying the
199 | recipient that the resource is intentionally unavailable and that the server owners desire that
200 | remote links to that resource be removed. Such an event is common for limited-time, promotional
201 | services and for resources belonging to individuals no longer working at the server's site. It
202 | is not necessary to mark all permanently unavailable resources as "gone" or to keep the mark
203 | for any length of time -- that is left to the discretion of the server owner.
204 | """
205 |
206 |
207 | _http_errors[410] = HttpGone
208 |
209 |
210 | class HttpLengthRequired(HttpClientError):
211 | """411 Length Required
212 |
213 | The server refuses to accept the request without a defined Content-Length. The client MAY
214 | repeat the request if it adds a valid Content-Length header field containing the length of the
215 | message-body in the request message.
216 | """
217 |
218 |
219 | _http_errors[411] = HttpLengthRequired
220 |
221 |
222 | class HttpPreconditionFailed(HttpClientError):
223 | """412 Precondition Failed
224 |
225 | The precondition given in one or more of the request-header fields evaluated to false when it
226 | was tested on the server. This response code allows the client to place preconditions on the
227 | current resource metainformation (header field data) and thus prevent the requested method from
228 | being applied to a resource other than the one intended.
229 | """
230 |
231 |
232 | _http_errors[412] = HttpPreconditionFailed
233 |
234 |
235 | class HttpRequestEntityTooLarge(HttpClientError):
236 | """413 Request Entity Too Large
237 |
238 | The server is refusing to process a request because the request entity is larger than the
239 | server is willing or able to process. The server MAY close the connection to prevent the client
240 | from continuing the request.
241 |
242 | If the condition is temporary, the server SHOULD include a Retry-After header field to indicate
243 | that it is temporary and after what time the client MAY try again.
244 | """
245 |
246 |
247 | _http_errors[413] = HttpRequestEntityTooLarge
248 |
249 |
250 | class HttpRequestUriTooLong(HttpClientError):
251 | """414 Request-URI Too Long
252 |
253 | The server is refusing to service the request because the Request-URI is longer than the server
254 | is willing to interpret. This rare condition is only likely to occur when a client has
255 | improperly converted a POST request to a GET request with long query information, when the
256 | client has descended into a URI "black hole" of redirection (e.g., a redirected URI prefix that
257 | points to a suffix of itself), or when the server is under attack by a client attempting to
258 | exploit security holes present in some servers using fixed-length buffers for reading or
259 | manipulating the Request-URI.
260 | """
261 |
262 |
263 | _http_errors[414] = HttpRequestUriTooLong
264 |
265 |
266 | class HttpUnsupportedMediaType(HttpClientError):
267 | """415 Unsupported Media Type
268 |
269 | The server is refusing to service the request because the entity of the request is in a format
270 | not supported by the requested resource for the requested method.
271 | """
272 |
273 |
274 | _http_errors[415] = HttpUnsupportedMediaType
275 |
276 |
277 | class HttpRequestedRangeNotSatisfiable(HttpClientError):
278 | """416 Requested Range Not Satisfiable
279 |
280 | A server SHOULD return a response with this status code if a request included a Range
281 | request-header field (section 14.35), and none of the range-specifier values in this field
282 | overlap the current extent of the selected resource, and the request did not include an
283 | If-Range request-header field. (For byte-ranges, this means that the first-byte-pos of all of
284 | the byte-range-spec values were greater than the current length of the selected resource.)
285 |
286 | When this status code is returned for a byte-range request, the response SHOULD include a
287 | Content-Range entity-header field specifying the current length of the selected resource (see
288 | section 14.16). This response MUST NOT use the multipart/byteranges content-type.
289 | """
290 |
291 |
292 | _http_errors[416] = HttpRequestedRangeNotSatisfiable
293 |
294 |
295 | class HttpExpectationFailed(HttpClientError):
296 | """417 Expectation Failed
297 |
298 | The expectation given in an Expect request-header field (see section 14.20) could not be met by
299 | this server, or, if the server is a proxy, the server has unambiguous evidence that the request
300 | could not be met by the next-hop server.
301 | """
302 |
303 |
304 | _http_errors[417] = HttpExpectationFailed
305 |
306 |
307 | class HttpTooManyRequests(HttpClientError):
308 | """429 Too Many Requests
309 |
310 | The 429 status code indicates that the user has sent too many
311 | requests in a given amount of time ("rate limiting")
312 |
313 | https://www.hipchat.com/docs/apiv2/rate_limiting
314 | """
315 |
316 |
317 | _http_errors[429] = HttpTooManyRequests
318 |
319 |
320 | class HttpServerError(requests.exceptions.HTTPError):
321 | """Server Error 5xx
322 |
323 | Response status codes beginning with the digit "5" indicate cases in which the server is aware
324 | that it has erred or is incapable of performing the request. Except when responding to a HEAD
325 | request, the server SHOULD include an entity containing an explanation of the error situation,
326 | and whether it is a temporary or permanent condition. User agents SHOULD display any included
327 | entity to the user. These response codes are applicable to any request method.
328 | """
329 |
330 |
331 | class HttpInternalServerError(HttpServerError):
332 | """500 Internal Server Error
333 |
334 | The server encountered an unexpected condition which prevented it from fulfilling the request.
335 | """
336 |
337 |
338 | _http_errors[500] = HttpInternalServerError
339 |
340 |
341 | class HttpNotImplemented(HttpServerError, NotImplementedError):
342 | """501 Not Implemented
343 |
344 | The server does not support the functionality required to fulfill the request. This is the
345 | appropriate response when the server does not recognize the request method and is not capable
346 | of supporting it for any resource.
347 | """
348 |
349 |
350 | _http_errors[501] = HttpNotImplemented
351 |
352 |
353 | class HttpBadGateway(HttpServerError):
354 | """502 Bad Gateway
355 |
356 | The server, while acting as a gateway or proxy, received an invalid response from the upstream
357 | server it accessed in attempting to fulfill the request.
358 | """
359 |
360 |
361 | _http_errors[502] = HttpBadGateway
362 |
363 |
364 | class HttpServiceUnavailable(HttpServerError):
365 | """503 Service Unavailable
366 |
367 | The server is currently unable to handle the request due to a temporary overloading or
368 | maintenance of the server. The implication is that this is a temporary condition which will be
369 | alleviated after some delay. If known, the length of the delay MAY be indicated in a
370 | Retry-After header. If no Retry-After is given, the client SHOULD handle the response as it
371 | would for a 500 response.
372 |
373 | Note: The existence of the 503 status code does not imply that a server must use it when
374 | becoming overloaded. Some servers may wish to simply refuse the connection.
375 | """
376 |
377 |
378 | _http_errors[503] = HttpServiceUnavailable
379 |
380 |
381 | class HttpGatewayTimeout(HttpServerError):
382 | """504 Gateway Timeout
383 |
384 | The server, while acting as a gateway or proxy, did not receive a timely response from the
385 | upstream server specified by the URI (e.g. HTTP, FTP, LDAP) or some other auxiliary server
386 | (e.g. DNS) it needed to access in attempting to complete the request.
387 |
388 | Note: Note to implementors: some deployed proxies are known to return 400 or 500 when DNS
389 | lookups time out.
390 | """
391 |
392 |
393 | _http_errors[504] = HttpGatewayTimeout
394 |
395 |
396 | class HttpVersionNotSupported(HttpServerError):
397 | """505 HTTP Version Not Supported
398 |
399 | The server does not support, or refuses to support, the HTTP protocol version that was used in
400 | the request message. The server is indicating that it is unable or unwilling to complete the
401 | request using the same major version as the client, as described in section 3.1, other than
402 | with this error message. The response SHOULD contain an entity describing why that version is
403 | not supported and what other protocols are supported by that server.
404 | """
405 |
406 |
407 | _http_errors[505] = HttpVersionNotSupported
408 |
409 |
410 | class Requests(requests.sessions.Session):
411 | """
412 | Class that extends the requests module in two ways:
413 | * Supports default arguments, for things like repeated Auth
414 | * Raise errors when requests go poorly
415 | """
416 |
417 | def __init__(self, **kwargs):
418 | self._template = kwargs.copy()
419 | super(Requests, self).__init__()
420 |
421 | def _kw(self, kwargs):
422 | kw = self._template.copy()
423 | kw.update(kwargs)
424 | return kw
425 |
426 | def request(self, method, url, **kwargs):
427 | rv = super(Requests, self).request(method, url, **self._kw(kwargs))
428 | # Raise one of our specific errors
429 | if rv.status_code in _http_errors:
430 | raise _http_errors[rv.status_code](rv.text, response=rv)
431 | # Try to raise for errors we didn't code for
432 | rv.raise_for_status()
433 | return rv
434 |
--------------------------------------------------------------------------------
/hypchat/restobject.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import, division
2 | import json
3 | import re
4 | import datetime
5 | import dateutil.parser
6 | import dateutil.tz
7 | import six
8 |
9 | _urls_to_objects = {}
10 |
11 |
12 | def timestamp(dt):
13 | """
14 | Parses a HipChat datetime value.
15 |
16 | HipChat uses ISO 8601, optionally with the timezone attached. Except for when they use a timestamp.
17 | """
18 | # '2013-12-05T22:42:18+00:00' <== History
19 | #'2013-11-27T15:33:24' <== Rooms, Users
20 | if dt is None:
21 | return
22 | if isinstance(dt, int):
23 | rv = datetime.datetime.fromtimestamp(dt, dateutil.tz.tzutc())
24 | elif dt.isdigit():
25 | rv = datetime.datetime.fromtimestamp(int(dt), dateutil.tz.tzutc())
26 | else:
27 | rv = dateutil.parser.parse(dt)
28 | if rv.tzinfo is None:
29 | rv = rv.replace(tzinfo=dateutil.tz.tzutc())
30 | return rv
31 |
32 |
33 | def mktimestamp(dt):
34 | """
35 | Prepares a datetime for sending to HipChat.
36 | """
37 | if dt.tzinfo is None:
38 | dt = dt.replace(tzinfo=dateutil.tz.tzutc())
39 | return dt.isoformat(), dt.tzinfo.tzname(dt)
40 |
41 |
42 | class Linker(object):
43 | """
44 | Responsible for on-demand loading of JSON objects.
45 | """
46 | url = None
47 |
48 | def __init__(self, url, parent=None, _requests=None):
49 | self.url = url
50 | self.__parent = parent
51 | self._requests = _requests or __import__('requests')
52 |
53 | @staticmethod
54 | def _obj_from_text(text, requests):
55 | """
56 | Constructs objects (including our wrapper classes) from a JSON-formatted string
57 | """
58 |
59 | def _object_hook(obj):
60 | if 'links' in obj:
61 | klass = RestObject
62 | if 'self' in obj['links']:
63 | for p, c in six.iteritems(_urls_to_objects):
64 | if p.match(obj['links']['self']):
65 | klass = c
66 | break
67 | rv = klass(obj)
68 | rv._requests = requests
69 | return rv
70 | else:
71 | return obj
72 |
73 | return json.JSONDecoder(object_hook=_object_hook).decode(text)
74 |
75 | def __call__(self, expand=None, **kwargs):
76 | """
77 | Actually perform the request
78 | """
79 | params = {}
80 | if expand is not None:
81 | if isinstance(expand, six.string_types):
82 | params = {'expand': expand}
83 | else:
84 | params = {'expand': ','.join(expand)}
85 | if kwargs:
86 | merge_params = {}
87 | for k, v in six.iteritems(kwargs):
88 | merge_params[k.replace('_', '-')] = v
89 | params.update(merge_params)
90 |
91 | rv = self._obj_from_text(self._requests.get(self.url, params=params).text, self._requests)
92 | rv._requests = self._requests
93 | if self.__parent is not None:
94 | rv.parent = self.__parent
95 | return rv
96 |
97 | def __repr__(self):
98 | return "<%s url=%r>" % (type(self).__name__, self.url)
99 |
100 |
101 | class RestObject(dict):
102 | """
103 | Nice wrapper around the JSON objects and their links.
104 | """
105 |
106 | def __getattr__(self, name):
107 | if name in self.get('links', {}):
108 | return Linker(self['links'][name], parent=self, _requests=self._requests)
109 | elif name in self:
110 | return self[name]
111 | else:
112 | raise AttributeError("%r object has no attribute %r" % (type(self).__name__, name))
113 |
114 | @property
115 | def url(self):
116 | return self['links']['self']
117 |
118 | def save(self):
119 | return self._requests.put(self.url, data=self).json()
120 |
121 | def delete(self):
122 | self._requests.delete(self.url)
123 |
124 |
125 | _at_mention = re.compile('@[\w]+(?: |$)')
126 |
127 |
128 | class Room(RestObject):
129 | def __init__(self, *p, **kw):
130 | super(Room, self).__init__(*p, **kw)
131 | if 'last_active' in self:
132 | self['last_active'] = timestamp(self['last_active'])
133 | if 'created' in self:
134 | self['created'] = timestamp(self['created'])
135 |
136 | def reply(self, message, parent_message_id):
137 | """
138 | Send a reply to a message
139 | """
140 | data = {'message': message, 'parentMessageId': parent_message_id}
141 | self._requests.post(self.url + '/reply', data=data)
142 |
143 | def message(self, message):
144 | """
145 | Allows a user to send a message to a room.
146 | """
147 | data = {'message': message}
148 | self._requests.post(self.url + '/message', data=data)
149 |
150 | def notification(self, message, color=None, notify=False, format=None):
151 | """
152 | Send a message to a room.
153 | """
154 | if not format:
155 | if len(_at_mention.findall(message)) > 0:
156 | format = 'text'
157 | else:
158 | format = 'html'
159 | data = {'message': message, 'notify': notify, 'message_format': format}
160 | if color:
161 | data['color'] = color
162 | self._requests.post(self.url + '/notification', data=data)
163 |
164 | def topic(self, text):
165 | """
166 | Set a room's topic. Useful for displaying statistics, important links, server status, you name it!
167 | """
168 | self._requests.put(self.url + '/topic', data={
169 | 'topic': text,
170 | })
171 |
172 | def history(self, date='recent', maxResults=200):
173 | """
174 | Requests the room history.
175 |
176 | Note that if date is 'recent' (the default), HipChat will not return the complete history.
177 | """
178 | tz = 'UTC'
179 | if date != 'recent':
180 | date, tz = mktimestamp(date)
181 | params = {
182 | 'date': date,
183 | 'timezone': tz,
184 | 'max-results': maxResults,
185 | }
186 | resp = self._requests.get(self.url + '/history', params=params)
187 | return Linker._obj_from_text(resp.text, self._requests)
188 |
189 | def latest(self, not_before=None, maxResults=200):
190 | """
191 | Return the latest room history.
192 |
193 | If ``not_before`` is provided, messages that precede the message id will not be returned
194 | """
195 | params = {
196 | "max-results": maxResults
197 | }
198 | if not_before is not None:
199 | params["not-before"] = not_before
200 |
201 | resp = self._requests.get(self.url + '/history/latest', params=params)
202 | return Linker._obj_from_text(resp.text, self._requests)
203 |
204 | def invite(self, user, reason):
205 | self._requests.post(self.url + '/invite/%s' % user['id'], data={
206 | 'reason': reason,
207 | })
208 |
209 | def create_webhook(self, url, event, pattern=None, name=None):
210 | """
211 | Creates a new webhook.
212 | """
213 | data = {
214 | 'url': url,
215 | 'event': event,
216 | 'pattern': pattern,
217 | 'name': name,
218 | }
219 | resp = self._requests.post(self.url + '/webhook', data=data)
220 | return Linker._obj_from_text(resp.text, self._requests)
221 |
222 | def save(self):
223 | data = {}
224 | for key in ('name', 'privacy', 'is_archived', 'is_guest_accessible', 'topic'):
225 | data[key] = self[key]
226 | data['owner'] = {'id': self['owner']['id']}
227 | headers = {'content-type': 'application/json'}
228 | return self._requests.put(self.url, data=json.dumps(data), headers=headers)
229 |
230 |
231 | _urls_to_objects[re.compile(r'https://[^/]+/v2/room/[^/]+$')] = Room
232 |
233 |
234 | class User(RestObject):
235 | def __init__(self, *p, **kw):
236 | super(User, self).__init__(*p, **kw)
237 | if 'last_active' in self:
238 | self['last_active'] = timestamp(self['last_active'])
239 | if 'created' in self:
240 | self['created'] = timestamp(self['created'])
241 |
242 | def message(self, message, message_format='text', notify=False):
243 | """
244 | Sends a user a private message.
245 | """
246 | self._requests.post(self.url + '/message', data={
247 | 'message': message,
248 | 'message_format': message_format,
249 | 'notify': notify,
250 | })
251 |
252 | def history(self, maxResults=200, notBefore=None):
253 | """
254 | Requests the users private message history.
255 |
256 | ::param not_before: the oldest message id to be returned. If not set the history is limited by maxResults only
257 | """
258 | tz = 'UTC'
259 | params = {
260 | 'timezone': tz,
261 | 'max-results': maxResults,
262 |
263 | }
264 | if notBefore is not None:
265 | params["not-before"] = notBefore
266 |
267 | resp = self._requests.get(self.url + '/history/latest', params=params)
268 | return Linker._obj_from_text(resp.text, self._requests)
269 |
270 | def save(self):
271 | data = {}
272 | for key, value in six.iteritems(self):
273 | if key == 'presence' and isinstance(value, dict):
274 | p = {}
275 | for k, v in six.iteritems(value):
276 | if k in ('status', 'show'):
277 | p[k] = v
278 | if len(p) != 0:
279 | data[key] = p
280 | else:
281 | data[key] = value
282 | self._requests.put(self.url, data=data)
283 |
284 |
285 | _urls_to_objects[re.compile(r'https://[^/]+/v2/user/[^/]+$')] = User
286 |
287 |
288 | class Collection(object):
289 | """
290 | Mixin for collections
291 | """
292 |
293 | def contents(self, **kwargs):
294 | page = self
295 | ops = {}
296 | if kwargs.get('expand'):
297 | ops['expand'] = 'items'
298 | while hasattr(page, 'next'):
299 | for item in page['items']:
300 | yield item
301 | page = page.next(**ops)
302 | # Last page handling
303 | for item in page['items']:
304 | yield item
305 |
306 |
307 | class MemberCollection(RestObject, Collection):
308 | def add(self, user):
309 | """
310 | Adds a member to a private room.
311 | """
312 | self._requests.put(self.url + '/%s' % user['id'])
313 |
314 | def remove(self, user):
315 | """
316 | Removes a member from a private room.
317 | """
318 | self._requests.delete(self.url + '/%s' % user['id'])
319 |
320 |
321 | _urls_to_objects[re.compile(r'https://[^/]+/v2/room/[^/]+/member$')] = MemberCollection
322 |
323 |
324 | class UserCollection(RestObject, Collection):
325 | def create(self, name, email, title=None, mention_name=None, is_group_admin=False, timezone='UTC', password=None):
326 | """
327 | Creates a new user.
328 | """
329 | data = {
330 | 'name': name,
331 | 'email': email,
332 | 'title': title,
333 | 'mention_name': mention_name,
334 | 'is_group_admin': is_group_admin,
335 | 'timezone': timezone, # TODO: Support timezone objects
336 | 'password': password,
337 | }
338 | resp = self._requests.post(self.url, data=data)
339 | return Linker._obj_from_text(resp.text, self._requests)
340 |
341 |
342 | _urls_to_objects[re.compile(r'https://[^/]+/v2/user$')] = UserCollection
343 |
344 |
345 | class RoomCollection(RestObject, Collection):
346 | def create(self, name, owner=Ellipsis, privacy='public', guest_access=True):
347 | """
348 | Creates a new room.
349 | """
350 | data = {
351 | 'name': name,
352 | 'privacy': privacy,
353 | 'guest_access': guest_access,
354 | }
355 | if owner is not Ellipsis:
356 | if owner is None:
357 | data['owner_user_id'] = owner
358 | else:
359 | data['owner_user_id'] = owner['id']
360 | resp = self._requests.post(self.url, data=data)
361 | return Linker._obj_from_text(resp.text, self._requests)
362 |
363 |
364 | _urls_to_objects[re.compile(r'https://[^/]+/v2/room$')] = RoomCollection
365 |
366 |
367 | class WebhookCollection(RestObject, Collection):
368 | def create(self, url, event, pattern=None, name=None):
369 | """
370 | Creates a new webhook.
371 | """
372 | data = {
373 | 'name': name,
374 | 'email': email,
375 | 'title': title,
376 | 'mention_name': mention_name,
377 | 'is_group_admin': is_group_admin,
378 | 'timezone': timezone, # TODO: Support timezone objects
379 | 'password': password,
380 | }
381 | resp = self._requests.post(self.url, data=data)
382 | return Linker._obj_from_text(resp.text, self._requests)
383 |
384 |
385 | _urls_to_objects[re.compile(r'https://[^/]+/v2/room/[^/]+/webhook$')] = WebhookCollection
386 |
387 |
388 | class EmoticonCollection(RestObject, Collection):
389 | pass
390 |
391 |
392 | _urls_to_objects[re.compile(r'https://[^/]+/v2/emoticon$')] = EmoticonCollection
393 |
394 |
395 | class Webhook(RestObject):
396 | def __init__(self, *p, **kw):
397 | super(Webhook, self).__init__(*p, **kw)
398 | if 'created' in self:
399 | self['created'] = timestamp(self['created'])
400 |
401 |
402 | _urls_to_objects[re.compile(r'https://[^/]+/v2/room/[^/]+/webhook/[^/]+$')] = Webhook
403 |
404 |
405 | class HistoryCollection(RestObject, Collection):
406 | def __init__(self, *p, **kw):
407 | super(HistoryCollection, self).__init__(*p, **kw)
408 | for item in self['items']:
409 | if 'date' in item:
410 | item['date'] = timestamp(item['date'])
411 |
412 |
413 | _urls_to_objects[re.compile(r'https://[^/]+/v2/room/[^/]+/history$')] = HistoryCollection
414 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | from distutils.core import setup, Command
4 |
5 |
6 | class PyTest(Command):
7 | user_options = []
8 |
9 | def initialize_options(self):
10 | pass
11 |
12 | def finalize_options(self):
13 | pass
14 |
15 | def run(self):
16 | import sys, subprocess
17 |
18 | errno = subprocess.call([sys.executable, '-m', 'unittest', 'discover'])
19 | raise SystemExit(errno)
20 |
21 |
22 | def read_file(name):
23 | """
24 | Read file content
25 | """
26 | f = open(name)
27 | try:
28 | return f.read()
29 | except IOError:
30 | print("could not read %r" % name)
31 | f.close()
32 |
33 |
34 | setup(name='hypchat',
35 | version='0.21',
36 | description="Package for HipChat's v2 API",
37 | long_description=read_file('README.rst'),
38 | author='Riders Discount',
39 | author_email='opensource@ridersdiscount.com',
40 | url='https://github.com/RidersDiscountCom/HypChat',
41 | packages=['hypchat'],
42 | install_requires=['requests', 'python-dateutil', 'six'],
43 | test_requires=['requests_mock'],
44 | provides=['hypchat'],
45 | cmdclass={'test': PyTest},
46 | classifiers=[ # https://pypi.python.org/pypi?%3Aaction=list_classifiers
47 | 'Development Status :: 3 - Alpha',
48 | 'Intended Audience :: Developers',
49 | 'License :: OSI Approved :: MIT License',
50 | 'Operating System :: OS Independent',
51 | 'Programming Language :: Python',
52 | 'Programming Language :: Python :: 2',
53 | 'Programming Language :: Python :: 3',
54 | 'Topic :: Communications :: Chat',
55 | 'Topic :: Software Development :: Libraries',
56 | 'Topic :: Software Development :: Libraries :: Python Modules',
57 | ]
58 | )
59 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RidersDiscountCom/HypChat/d38e1a15031fdb11694c9ed971eb3e7e67e70907/tests/__init__.py
--------------------------------------------------------------------------------
/tests/common.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | import sys
3 | import os, os.path
4 | import ConfigParser
5 |
6 | package = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
7 | sys.path.insert(0, package)
8 |
9 | import hypchat
10 |
11 |
12 | class TestHypChat(unittest.TestCase):
13 | def setUp(self):
14 | self.setUpConfig()
15 | self.setUpHipChat()
16 |
17 | def setUpConfig(self):
18 | search_paths = [os.path.expanduser('~/.hypchat'), '/etc/hypchat']
19 |
20 | self.config = ConfigParser.ConfigParser()
21 | self.config.read(search_paths)
22 | if self.config.has_section('HipChat'):
23 | self.access_token = self.config.get('HipChat', 'token')
24 | elif 'HIPCHAT_TOKEN' in os.environ:
25 | self.access_token = os.environ['HIPCHAT_TOKEN']
26 | else:
27 | print('Authorization token not detected! The token is pulled from ' \
28 | '~/.hypchat, /etc/hypchat, or the environment variable HIPCHAT_TOKEN.')
29 |
30 | def setUpHipChat(self):
31 | self.hipchat = hypchat.HypChat(self.access_token)
--------------------------------------------------------------------------------
/tests/test_rate_limit.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | import time
3 | import requests_mock
4 |
5 | from common import TestHypChat
6 |
7 |
8 | class TestRateLimit(TestHypChat):
9 | def setUp(self):
10 | super(TestRateLimit, self).setUp()
11 | self.call_counter = 1
12 |
13 | def successfull_callback(self, request, context):
14 | context.status_code = 200
15 |
16 | headers = {
17 | 'X-Ratelimit-Limit': '100',
18 | 'X-Ratelimit-Remaining': '99',
19 | 'X-Ratelimit-Reset': str(time.time())
20 | }
21 |
22 | context.headers = headers
23 |
24 | payload = {
25 | 'atlassian_id': None,
26 | 'created': '2014-09-26T10:44:04+00:00',
27 | 'email': 'john.doe@example.com',
28 | 'group': None,
29 | 'id': 12345,
30 | 'is_deleted': False,
31 | 'is_group_admin': True,
32 | 'is_guest': False,
33 | 'last_active': str(int(time.time())),
34 | 'links': {
35 | 'self': 'https://api.hipchat.com/v2/user/12345'
36 | },
37 | 'mention_name': 'john',
38 | 'name': 'John Doe',
39 | 'photo_url': 'https://s3.amazonaws.com/uploads.hipchat.com/photos/12345/ABCDEFG.jpg',
40 | 'presence': {
41 | 'client': {
42 | 'type': 'http://hipchat.com/client/mac',
43 | 'version': '12345'
44 | },
45 | 'is_online': True
46 | },
47 | 'timezone': 'Europe/Berlin',
48 | 'title': 'Software Engineer',
49 | 'xmpp_jid': '12345@chat.hipchat.com'
50 | }
51 |
52 | return payload
53 |
54 | def rate_limited_callback(self, request, context):
55 | context.status_code = 429
56 |
57 | headers = {
58 | 'X-Ratelimit-Limit': '100',
59 | 'X-Ratelimit-Remaining': '0',
60 | 'X-Ratelimit-Reset': str(time.time() + 0.1)
61 | }
62 |
63 | context.headers = headers
64 |
65 | payload = {
66 | 'error': {
67 | 'code': 429,
68 | 'type': 'Too Many Requests',
69 | 'message': 'Rate Limit exceeded'
70 | }
71 | }
72 |
73 | return payload
74 |
75 | def hipchat_callback(self, request, context):
76 | rate_limit = self.call_counter % 2 == 0
77 | self.call_counter += 1
78 |
79 | if rate_limit:
80 | return self.rate_limited_callback(request, context)
81 | else:
82 | return self.successfull_callback(request, context)
83 |
84 |
85 | def runTest(self):
86 | """
87 | We are mocking the request so every second call is rate limited.
88 | """
89 | with requests_mock.Mocker() as m:
90 | m.register_uri('GET', 'https://api.hipchat.com/v2/user/@john', status_code=200, json=self.hipchat_callback)
91 | for i in xrange(3):
92 | self.hipchat.get_user('@john')
--------------------------------------------------------------------------------