/
send.py
481 lines (364 loc) · 17 KB
/
send.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
# -*- coding: utf-8 -*-
"""API Client."""
from __future__ import print_function
from __future__ import absolute_import
from os import fdopen
from xml.etree import ElementTree
import re
import sys
import tempfile
import logging
try:
import requests
from requests.auth import HTTPDigestAuth
except ImportError:
sys.exit('Install python-requests or python3-requests')
from ._version import __version__
# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! #
# If MYTHTV_VERSION_LIST needs to be changed, be sure to #
# test with the new back/frontend version. If you're just #
# getting data, no harm will be done. But if you Add/Delete/ #
# Update anything, then all bets are off! Anything requiring #
# an HTTP POST is potentially dangerous. #
# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! #
MYTHTV_VERSION_LIST = ('0.27', '0.28', '29', '30', '31', '32')
class Send(object):
"""Services API."""
def __init__(self, host, port=6544):
"""
INPUT:
======
host: Must be set and is the hostname or IP address of the backend
or frontend.
port: Only needed if the backend is using a different port
(unlikely) or set to the frontend port, which is usually
6547. Defaults to 6544.
"""
if not host:
raise RuntimeError('Missing host argument')
self.host = host
self.port = port
self.endpoint = None
self.postdata = None
self.rest = None
self.opts = None
self.session = None
self.server_version = 'Set to MythTV version after calls to send()'
self.logger = logging.getLogger(__name__)
logging.getLogger(__name__).addHandler(logging.NullHandler())
def send(self, endpoint='', postdata=None, rest='', opts=None):
"""
Form a URL and send it to the back/frontend. Parameter/option checking
and session creation (if required) is done here. Error handling is done
here too.
EXAMPLES:
=========
import MythTV.services_api.send as send
backend = send.Send(host='someName')
backend.send(endpoint='Myth/GetHostName')
Returns: {'String': 'someBackend'}
frontend = send.Send(host='someFrontend', port=6547)
frontend.send(endpoint='Frontend/GetStatus')
Returns: {'FrontendStatus': {'AudioTracks': {}, 'Name': 'someHost', ...
INPUT:
======
endpoint: Must be set. Example: Myth/GetHostName
postdata: May be set if the endpoint allows it. Used when information
is added/changed/deleted. postdata is passed as a Python
dict e.g. {'ChanId':1071, ...}. Don't use if rest is used.
The HTTP method will be a POST (as opposed to a GET.)
If using postdata, TAKE CAUTION!!! Use opts['wrmi']=False
1st, turn on DEBUG level logging and then when happy with
the data, make opts['wrmi']=True.
N.B. The MythTV Services API is still evolving and the wise
user will backup their DB before including postdata.
rest: May be set if the endpoint allows it. For example, endpoint=
Myth/GetRecordedList, rest='Count=10&StorageGroup=Sports'
Don't use if postdata is used. The HTTP method will be a GET.
opts (Short Description):
It's OK to call this function without any options set and:
. If there's postdata, nothing will be sent to the server
. timeout will be set to 10 seconds
. It will fail if the backend requires authorization (user/pass
would be required)
opts (Details):
opts is a dictionary of options that may be set in the calling program.
Default values will be used if callers don't pass all or some of their
own. The defaults are all False except for 'user', 'pass' and
'timeout'.
opts['noetag']: Don't request the back/frontend to check for matching
ETag. Mostly for testing.
opts['nogzip']: Don't request the back/frontend to gzip it's response.
Useful if watching protocol with a tool that doesn't
uncompress it.
opts['timeout']: May be set, in seconds. Examples: 5, 0.01. Used to
prevent script from waiting indefinitely for a reply
from the server. Note: a timeout exception is only
raised if there are no bytes received from the host on
this socket. Long downloads are not affected by this
option. Defaults to 10 seconds.
opts['user']: Digest authentication. Usually not turned on in the
opts['pass']: backend.
opts['usexml']: For testing only! If True, causes the backend to send
its response in XML rather than JSON. Defaults to
False.
opts['wrmi']: If True and there is postdata, the URL is then sent to
the server.
If opts['wrmi'] is False and there is postdata, send
NOTHING to the server.
This is a fail-safe that allows testing. Users can
examine what's about to be sent before doing it (wrmi
means: We Really Mean It.)
opts['wsdl']: If True return WSDL from the back/frontend. Accepts no
rest or postdata. Just set endpoint, e.g. Content/wsdl
OUTPUT:
=======
Either the response from the server in the selected format (default is
JSON.) Or an exception of RuntimeWarning or RuntimeError.
Callers can handle the response like this:
backend = send.Send(host=...)
try:
response = backend.send(...)
except RuntimeError as error:
handle error...
except RuntimeWarning as warning:
handle warning...
normal processing...
If an Image filename is returned, then the caller is responsible for
deleting the temporary filename which is returned in a RuntimeWarning
exception, e.g.:
'Image file = "/tmp/tmp5pxynqdf.jpeg"'
However, some errors returned by the server are in XML, e.g. if an
endpoint is invalid. That will cause the JSON decoder to fail. In
the application calling this, turn logging on and use the DEBUG
level. See the next section.
Whenever send() is used, 'server_version' is set to the value returned
by the back/frontend in the HTTP Server: header. It is saved as just
the version, e.g. 0.28. Callers can check it and *may* choose to adjust
their code work with other versions. See: get_server_version().
LOGGING:
========
Callers may choose to use the Python logging module. Lines similar to
the following will make log() statements print if the level is set to
DEBUG:
import logging
logging.basicConfig(level=logging.DEBUG
if args['debug'] else logging.INFO)
logging.getLogger('requests.packages.urllib3')
.setLevel(logging.WARNING)
logging.getLogger('urllib3.connectionpool')
.setLevel(logging.WARNING)
"""
self.endpoint = endpoint
self.postdata = postdata
self.rest = rest
self.opts = opts
self._set_missing_opts()
url = self._form_url()
self.logger.debug('URL=%s', url)
if self.session is None:
self._create_session()
if self.postdata:
self._validate_postdata()
exceptions = (requests.exceptions.HTTPError,
requests.exceptions.URLRequired,
requests.exceptions.Timeout,
requests.exceptions.ConnectionError,
requests.exceptions.InvalidURL,
KeyboardInterrupt)
##############################################################
# Actually try to get the data and handle errors. #
##############################################################
try:
if self.postdata:
response = self.session.post(url, data=self.postdata,
timeout=self.opts['timeout'])
else:
response = self.session.get(url, timeout=self.opts['timeout'])
except exceptions:
raise RuntimeError('Connection problem/Keyboard Interrupt, URL={}'
.format(url))
if response.status_code == 401:
raise RuntimeError('Unauthorized (401). Need valid user/password.')
# TODO: Should handle redirects here (mostly for remote backends.)
if response.status_code > 299:
self.logger.debug('%s', response.text)
reason = (ElementTree.fromstring(response.text)
.find('errorDescription').text)
raise RuntimeError('Unexpected status returned: {}: Reason: "{}" '
'URL was: {}'
.format(response.status_code,
reason, url))
self._validate_header(response.headers['Server'])
self.logger.debug('Response headers: %s', response.headers)
if response.encoding is None:
response.encoding = 'UTF8'
try:
ct_header, image_type = response.headers['Content-Type'].split('/')
except (KeyError, ValueError):
ct_header = None
##############################################################
# Finally, return the response in the desired format #
##############################################################
if self.opts['wsdl']:
return {'WSDL': response.text}
if ct_header == 'image':
handle, filename = tempfile.mkstemp(suffix='.' + image_type)
self.logger.debug('created %s, remember to delete it.', filename)
with fdopen(handle, 'wb') as f_obj:
for chunk in response.iter_content(chunk_size=8192):
f_obj.write(chunk)
raise RuntimeWarning('Image file = "{}"'.format(filename))
else:
try:
self.logger.debug('1st 60 bytes of response: %s',
response.text[:60])
except UnicodeEncodeError:
pass
if self.opts['usexml']:
return response.text
try:
return response.json()
except ValueError as err:
raise RuntimeError('Set loglevel=DEBUG to see JSON parse error: {}'
.format(err))
def close_session(self):
"""
This is here for unit tests that need to start a new session
so that noetag, nogzip and usexml (that were configured in
_create_session()) can be changed.
"""
self.session.close()
def _set_missing_opts(self):
"""
Sets options not set by the caller to False (or 10 in the
case of timeout.
"""
if not isinstance(self.opts, dict):
self.opts = {}
for option in ('noetag', 'nogzip', 'usexml', 'wrmi', 'wsdl'):
try:
self.opts[option]
except (KeyError, TypeError):
self.opts[option] = False
try:
self.opts['timeout']
except KeyError:
self.opts['timeout'] = 10
self.logger.debug('opts=%s', self.opts)
return
def _form_url(self):
"""Do basic sanity checks and then form the URL."""
if self.host == '':
raise RuntimeError('No host name.')
if not self.endpoint:
raise RuntimeError('No endpoint (e.g. Myth/GetHostName.)')
if self.postdata and self.rest:
raise RuntimeError('Use either postdata or rest, not both.')
if self.opts['wsdl'] and self.rest:
raise RuntimeError('usage: rest not allowed with WSDL')
if not self.rest:
self.rest = ''
else:
self.rest = '?' + self.rest
return 'http://{}:{}/{}{}'.format(self.host, self.port, self.endpoint,
self.rest)
def _validate_postdata(self):
"""
Return a RuntimeError if the postdata passed doesn't make sense. Call
this only if there is postdata.
"""
if not isinstance(self.postdata, dict):
raise RuntimeError('usage: postdata must be passed as a dict')
self.logger.debug('The following postdata was included:')
for key in self.postdata:
self.logger.debug('%15s: %s', key, self.postdata[key])
if not self.opts['wrmi']:
raise RuntimeWarning('wrmi=False')
if self.opts['wsdl'] and self.postdata:
raise RuntimeError('usage: postdata not allowed with WSDL')
def _create_session(self):
"""
Called if a session doesn't already exist. Sets the desired
headers and provides for authentication.
"""
self.session = requests.Session()
self.session.headers.update({'User-Agent': 'Python Services API v{}'
.format(__version__)})
if self.opts['noetag']:
self.session.headers.update({'Cache-Control': 'no-store'})
self.session.headers.update({'If-None-Match': ''})
if self.opts['nogzip']:
self.session.headers.update({'Accept-Encoding': ''})
else:
self.session.headers.update({'Accept-Encoding': 'gzip,deflate'})
if self.opts['usexml']:
self.session.headers.update({'Accept': ''})
else:
self.session.headers.update({'Accept': 'application/json'})
self.logger.debug('New session')
# TODO: Problem with the BE not accepting postdata in the initial
# authorized query, Send a GET first as a workaround.
try:
if self.opts['user'] and self.opts['pass']:
self.session.auth = HTTPDigestAuth(self.opts['user'],
self.opts['pass'])
if self.postdata:
saved_endpoint = self.endpoint
saved_postdata = self.postdata
# Need to adjust this if a service other than Frontend is
# added.
self.send(endpoint='{}/version'.format(
'Myth' if self.endpoint[:8] != 'Frontend'
else 'Frontend'), opts=self.opts)
self.endpoint = saved_endpoint
self.postdata = saved_postdata
except KeyError:
# Proceed without authentication.
pass
def _validate_header(self, header):
"""
Process the contents of the HTTP Server: header. Try to see
what version the server is running on. The tested versions
are kept in MYTHTV_VERSION_LIST and checked against responses
like:
MythTV/30-Pre-9-g1234567-dirty Linux/3.13.0-85-generic UPnP/1.0.
MythTV/29-pre-5-g6865940-dirty Linux/3.13.0-85-generic UPnP/1.0.
MythTV/0.28.0-10-g57c1afb Linux/4.4.0-21-generic UPnP/1.0.
Linux 3.13.0-65-generic, UPnP/1.0, MythTV 0.27.20150622-1
"""
if not header:
raise RuntimeError('No HTTP Server header returned from host {}.'
.format(self.host))
self.logger.debug('Received Server: %s', header)
for version in MYTHTV_VERSION_LIST:
if re.search('MythTV.' + version, header):
self.server_version = version
return
raise RuntimeError('Tested on {}, not: {}.'
.format(MYTHTV_VERSION_LIST, header))
@property
def get_server_version(self):
"""
Returns the version of the back/frontend. Only works after send()
has been called.
"""
return self.server_version
@property
def get_opts(self):
"""
Returns all opts{}, whether set manually or automatically.
"""
return self.opts
def get_headers(self, header=None):
"""
Returns the requested header or all headers if none is specified.
These are the headers sent, not those received from the backend.
"""
if not self.session.headers:
self.logger.debug('No headers yet, call send() 1st.')
return None
if not header:
return self.session.headers
return self.session.headers[header]
# vim: set expandtab tabstop=4 shiftwidth=4 smartindent noai colorcolumn=80: