1 | #!/usr/bin//python3 |
---|
2 | # -*- coding: utf-8 -*- |
---|
3 | |
---|
4 | ''' |
---|
5 | This is really a combination of unit tests and integration tests, so |
---|
6 | take the name with a grain of salt. |
---|
7 | |
---|
8 | NOTE: The value of the TEST_* globals below must be changed manually for |
---|
9 | the system under test! |
---|
10 | |
---|
11 | ALSO: The user/pass opts are hardcoded to the MythTV default of |
---|
12 | admin/mythtv. It's unlikely that most will even be running with |
---|
13 | digest protection on. |
---|
14 | ''' |
---|
15 | |
---|
16 | # pylint: disable=protected-access,global-at-module-level,global-statement |
---|
17 | |
---|
18 | import logging |
---|
19 | import unittest |
---|
20 | import requests |
---|
21 | from MythTV.services_api import (send as api, utilities as util) |
---|
22 | from MythTV.services_api._version import __version__ |
---|
23 | |
---|
24 | global BACKEND |
---|
25 | BACKEND = None |
---|
26 | |
---|
27 | # Adjust for the system under test: |
---|
28 | TEST_DVR_VERSION = '6.7' |
---|
29 | TEST_HOST = 'ofc0' |
---|
30 | TEST_SERVER_VERSION = '31' |
---|
31 | TEST_UTC_OFFSET = -5 # CDT=-5 # CST=-6 |
---|
32 | TEST_SECONDS_OFFSET = (TEST_UTC_OFFSET * 3600) |
---|
33 | |
---|
34 | # These two are most likely OK: |
---|
35 | TEST_ENDPOINT = 'Dvr/version' |
---|
36 | TEST_PORT = 6544 |
---|
37 | |
---|
38 | REC_STATUS_DATA = { |
---|
39 | # rec_status: expect |
---|
40 | -17: 'Unknown', |
---|
41 | -11: 'Missed', |
---|
42 | -10: 'Tuning', |
---|
43 | -9: 'Recorder Failed', |
---|
44 | -8: 'Tuner Busy', |
---|
45 | -7: 'Low Disk Space', |
---|
46 | -6: 'Manual Cancel', |
---|
47 | -5: 'Missed', |
---|
48 | -4: 'Aborted', |
---|
49 | -3: 'Recorded', |
---|
50 | -2: 'Recording', |
---|
51 | 3: 'Currently Recorded', |
---|
52 | 4: 'Earlier Showing', |
---|
53 | 5: 'Max Recordings', |
---|
54 | 6: 'Not Listed', |
---|
55 | 12: 'Recorder Off-Line', |
---|
56 | 13: 'Unknown', |
---|
57 | } |
---|
58 | |
---|
59 | |
---|
60 | class MythTVServicesAPI(unittest.TestCase): |
---|
61 | ''' Test the MythTV Services API''' |
---|
62 | |
---|
63 | def setUp(self): |
---|
64 | ''' |
---|
65 | Called before every one of the following test_*()s. Guarantee |
---|
66 | that the session's options are set to their default values |
---|
67 | and that the UTC offset is set. |
---|
68 | ''' |
---|
69 | |
---|
70 | global BACKEND |
---|
71 | |
---|
72 | # self.longMessage = True |
---|
73 | |
---|
74 | if BACKEND: |
---|
75 | BACKEND.close_session() |
---|
76 | |
---|
77 | opts = {'user': 'admin', 'pass': 'mythtv'} |
---|
78 | BACKEND = api.Send(host=TEST_HOST) |
---|
79 | self.assertIsInstance(BACKEND, api.Send) |
---|
80 | self.assertEqual(BACKEND.send(endpoint=TEST_ENDPOINT, |
---|
81 | opts=opts)['String'], |
---|
82 | TEST_DVR_VERSION) |
---|
83 | self.assertEqual(util.get_utc_offset(backend=BACKEND), |
---|
84 | TEST_SECONDS_OFFSET) |
---|
85 | |
---|
86 | def test_access(self): |
---|
87 | ''' |
---|
88 | Do additional basic access tests that setUp() doesn't need to |
---|
89 | do every time it's called. |
---|
90 | ''' |
---|
91 | |
---|
92 | self.assertTrue(BACKEND.server_version == TEST_SERVER_VERSION) |
---|
93 | self.assertIsInstance(BACKEND.get_headers(), |
---|
94 | requests.structures.CaseInsensitiveDict) |
---|
95 | |
---|
96 | BACKEND.close_session() |
---|
97 | with self.assertRaisesRegex(RuntimeError, 'Missing host argument'): |
---|
98 | api.Send(host=None) |
---|
99 | |
---|
100 | def test_default_opts(self): |
---|
101 | ''' |
---|
102 | Test default option values |
---|
103 | |
---|
104 | All default values are False except timeout, which is 10. |
---|
105 | ''' |
---|
106 | |
---|
107 | global BACKEND |
---|
108 | |
---|
109 | for key, value in BACKEND.get_opts.items(): |
---|
110 | if key == 'timeout': |
---|
111 | self.assertEqual(value, 10) |
---|
112 | elif key in ('user', 'pass'): |
---|
113 | pass |
---|
114 | else: |
---|
115 | self.assertFalse(value) |
---|
116 | |
---|
117 | response = '{"String": "' + TEST_DVR_VERSION + '"}' |
---|
118 | self.assertEqual(BACKEND.send(endpoint=TEST_ENDPOINT, |
---|
119 | opts={'wsdl': True}), {'WSDL': response}) |
---|
120 | |
---|
121 | session_options = { |
---|
122 | # option: expect |
---|
123 | 'noetag': "{'String': ", |
---|
124 | 'nogzip': "{'String': ", |
---|
125 | 'usexml': '<?xml version="1.0" encoding="UTF-8"?><String>', |
---|
126 | } |
---|
127 | |
---|
128 | expected_headers = { |
---|
129 | # option: header, value (0, 1) |
---|
130 | 'noetag': ('If-None-Match', ''), |
---|
131 | 'nogzip': ('Accept-Encoding', ''), |
---|
132 | 'usexml': ('Accept', ''), |
---|
133 | } |
---|
134 | |
---|
135 | for option, expect in session_options.items(): |
---|
136 | BACKEND.close_session() |
---|
137 | BACKEND = api.Send(host=TEST_HOST) |
---|
138 | opts = {option: True, 'user': 'admin', 'pass': 'mythtv'} |
---|
139 | response = str(BACKEND.send(endpoint=TEST_ENDPOINT, |
---|
140 | opts=opts)) |
---|
141 | |
---|
142 | self.assertIn(expect, response) |
---|
143 | |
---|
144 | self.assertEqual(BACKEND.get_headers( |
---|
145 | header=expected_headers[option][0]), |
---|
146 | expected_headers[option][1]) |
---|
147 | |
---|
148 | # @unittest.skip('Uncomment to skip this test') |
---|
149 | def test_digest(self): |
---|
150 | ''' |
---|
151 | Verify that bad digest user and passwords fail. |
---|
152 | ''' |
---|
153 | |
---|
154 | global BACKEND |
---|
155 | |
---|
156 | # Save the existing protected service(s)... |
---|
157 | BACKEND.close_session() |
---|
158 | BACKEND = api.Send(host=TEST_HOST) |
---|
159 | kwargs = {'opts': {'user': 'admin', 'pass': 'mythtv', 'wrmi': True}, |
---|
160 | 'postdata': {'Key': 'HTTP/Protected/Urls', |
---|
161 | 'HostName': '_GLOBAL_'}} |
---|
162 | value = BACKEND.send(endpoint='Myth/GetSetting', **kwargs)['String'] |
---|
163 | self.assertIsNotNone(value) |
---|
164 | |
---|
165 | url_protection = 'Myth/ManageUrlProtection' |
---|
166 | |
---|
167 | # Turn authentication on... |
---|
168 | kwargs = {'opts': {'user': 'admin', 'pass': 'mythtv', 'wrmi': True}, |
---|
169 | 'postdata': {'Services': 'All', 'AdminPassword': 'mythtv'}} |
---|
170 | self.assertEqual(BACKEND.send(endpoint=url_protection, **kwargs), |
---|
171 | {'bool': 'true'}) |
---|
172 | |
---|
173 | # Create a new session and try a POST with an invalid password... |
---|
174 | BACKEND.close_session() |
---|
175 | BACKEND = api.Send(host=TEST_HOST) |
---|
176 | kwargs = {'opts': {'user': 'admin', 'pass': 'XmythtvX', 'wrmi': True}, |
---|
177 | 'postdata': {'Services': 'All', 'AdminPassword': 'mythtv'}} |
---|
178 | with self.assertRaisesRegex(RuntimeError, |
---|
179 | r'Unauthorized \(401\)..*password'): |
---|
180 | BACKEND.send(endpoint=url_protection, **kwargs) |
---|
181 | |
---|
182 | # Turn authentication back off... |
---|
183 | BACKEND.close_session() |
---|
184 | BACKEND = api.Send(host=TEST_HOST) |
---|
185 | kwargs = {'opts': {'user': 'admin', 'pass': 'mythtv', 'wrmi': True}, |
---|
186 | 'postdata': {'Services': 'None', 'AdminPassword': 'mythtv'}} |
---|
187 | self.assertEqual(BACKEND.send(endpoint=url_protection, **kwargs), |
---|
188 | {'bool': 'true'}) |
---|
189 | |
---|
190 | def test_headers_using_default_opts(self): |
---|
191 | ''' |
---|
192 | Test headers with all options False |
---|
193 | ''' |
---|
194 | |
---|
195 | user_agent = 'Python Services API v' + __version__ |
---|
196 | headers_with_no_options_set = { |
---|
197 | # header: value |
---|
198 | 'Accept-Encoding': 'gzip,deflate', |
---|
199 | 'Connection': 'keep-alive', |
---|
200 | 'User-Agent': user_agent, |
---|
201 | 'Accept': 'application/json' |
---|
202 | } |
---|
203 | |
---|
204 | for header, value in headers_with_no_options_set.items(): |
---|
205 | self.assertEqual(BACKEND.get_headers(header=header), value) |
---|
206 | |
---|
207 | def test_runtime_exceptions(self): |
---|
208 | ''' |
---|
209 | Testing Runtime* exceptions |
---|
210 | |
---|
211 | In these tests, we're indirectly testing the _form_url() function. |
---|
212 | ''' |
---|
213 | |
---|
214 | # Empty endpoint combinations |
---|
215 | with self.assertRaisesRegex(RuntimeError, 'No endpoint'): |
---|
216 | BACKEND.send() |
---|
217 | with self.assertRaisesRegex(RuntimeError, 'No endpoint'): |
---|
218 | BACKEND.send(endpoint='') |
---|
219 | with self.assertRaisesRegex(RuntimeError, 'No endpoint'): |
---|
220 | BACKEND.send(endpoint=None) |
---|
221 | |
---|
222 | # Invalid endpoint, backend will return a 404 |
---|
223 | with self.assertRaisesRegex(RuntimeError, |
---|
224 | 'Unexpected status returned: 404'): |
---|
225 | BACKEND.send(endpoint='Myth/InvalidEndpoint') |
---|
226 | |
---|
227 | # Illegal rest and postdata |
---|
228 | args = {'endpoint': TEST_ENDPOINT} |
---|
229 | kwargs = {'rest': 'Who=Cares', |
---|
230 | 'opts': {'wrmi': True}, |
---|
231 | 'postdata': {'Some': 'Junk'}} |
---|
232 | with self.assertRaisesRegex(RuntimeError, |
---|
233 | 'Use either postdata or rest'): |
---|
234 | BACKEND.send(*args, **kwargs) |
---|
235 | |
---|
236 | def test_validate_postdata_excepts(self): |
---|
237 | ''' |
---|
238 | Test runtime exceptions generated by _validate_postdata() |
---|
239 | ''' |
---|
240 | |
---|
241 | args = {'endpoint': 'Myth/PutSetting'} |
---|
242 | kwargs = {'postdata': {'Key': 'FakeSetting', 'HostName': TEST_HOST}} |
---|
243 | |
---|
244 | # Postdata not a dict, *kwargs is intentionally missing a * |
---|
245 | with self.assertRaisesRegex(RuntimeError, |
---|
246 | 'usage: postdata must be passed as a dic'): |
---|
247 | BACKEND.send(*args, *kwargs) |
---|
248 | |
---|
249 | # Need wrmi=True for postdata |
---|
250 | with self.assertRaisesRegex(RuntimeWarning, 'wrmi=False'): |
---|
251 | BACKEND.send(*args, **kwargs) |
---|
252 | |
---|
253 | # Final two tests, make sure wsdl can't be used with rest or postdata |
---|
254 | kwargs = {'opts': {'wrmi': True, 'wsdl': True}, |
---|
255 | 'rest': 'Who=Cares'} |
---|
256 | with self.assertRaisesRegex(RuntimeError, |
---|
257 | 'usage: rest not allowed with WSDL'): |
---|
258 | BACKEND.send(*args, **kwargs) |
---|
259 | |
---|
260 | kwargs = {'opts': {'wrmi': True, 'wsdl': True}, |
---|
261 | 'postdata': {'Some': 'More Junk'}} |
---|
262 | with self.assertRaisesRegex(RuntimeError, |
---|
263 | 'usage: postdata not allowed with WSDL'): |
---|
264 | BACKEND.send(*args, **kwargs) |
---|
265 | |
---|
266 | def test_form_url(self): |
---|
267 | ''' |
---|
268 | Test _form_url(), which has no exceptions. It just returns a URL, |
---|
269 | which is developed in the setUp() method. Watch out if the URL |
---|
270 | is changed there! |
---|
271 | ''' |
---|
272 | |
---|
273 | url = 'http://{}:{}/{}'.format(TEST_HOST, TEST_PORT, TEST_ENDPOINT) |
---|
274 | self.assertEqual(BACKEND._form_url(), url) |
---|
275 | |
---|
276 | def test_validate_header(self): |
---|
277 | ''' |
---|
278 | Test _validate_header() RuntimeError exceptions |
---|
279 | ''' |
---|
280 | |
---|
281 | header_data = { |
---|
282 | # header: response |
---|
283 | None: 'No HTTP Server header returned from host', |
---|
284 | '': 'No HTTP Server header returned from host', |
---|
285 | 'MythTV/99 Linux/3.13.0-85-generic': 'Tested on.*not:', |
---|
286 | } |
---|
287 | |
---|
288 | for header, response in header_data.items(): |
---|
289 | with self.assertRaisesRegex(RuntimeError, response): |
---|
290 | BACKEND._validate_header(header) |
---|
291 | |
---|
292 | # Test valid cases |
---|
293 | header_data = { |
---|
294 | 'MythTV/31-Pre-43-g2bb8237 Linux/4.4.0-141-generic UPnP/1.0', |
---|
295 | 'MythTV/30-Pre-9-g1234567 Linux/3.13.0-85-generic UPnP/1.0', |
---|
296 | 'MythTV/29-pre-5-g6865940 Linux/3.13.0-85-generic UPnP/1.0', |
---|
297 | 'MythTV/0.28.0-10-g57c1afb Linux/4.4.0-21-generic UPnP/1.0', |
---|
298 | 'Linux 3.13.0-65-generic, UPnP/1.0, MythTV 0.27.2015', |
---|
299 | } |
---|
300 | |
---|
301 | for header in header_data: |
---|
302 | self.assertEqual(BACKEND._validate_header(header), None) |
---|
303 | |
---|
304 | def test_get_utc_offset(self): |
---|
305 | ''' |
---|
306 | Test get_utc_offset(), which has already been used, but |
---|
307 | check the error cases too. |
---|
308 | ''' |
---|
309 | |
---|
310 | self.assertEqual(util.get_utc_offset(backend=None, opts=None), -1) |
---|
311 | self.assertEqual(util.get_utc_offset(backend='', opts=None), -1) |
---|
312 | self.assertEqual(util.get_utc_offset(backend=BACKEND, opts=None), |
---|
313 | TEST_SECONDS_OFFSET) |
---|
314 | |
---|
315 | def test_create_find_time(self): |
---|
316 | ''' |
---|
317 | Test create_find_time() |
---|
318 | ''' |
---|
319 | |
---|
320 | test_hour = '{:2}'.format(24+TEST_UTC_OFFSET) |
---|
321 | |
---|
322 | create_find_time_data = { |
---|
323 | # time: response |
---|
324 | None: None, |
---|
325 | '': None, |
---|
326 | '20170101 08:01:02': -1, |
---|
327 | '2017-01-01 09:01:02': -1, |
---|
328 | '2017-01-01T00:01:02': '{}:01:02'.format(test_hour), |
---|
329 | '2017-01-01T00:01:03Z': '{}:01:03'.format(test_hour), |
---|
330 | '2017-11-21T00:01:04Z': '{}:01:04'.format(test_hour), |
---|
331 | } |
---|
332 | |
---|
333 | for time, response in create_find_time_data.items(): |
---|
334 | self.assertEqual(util.create_find_time(time), response) |
---|
335 | |
---|
336 | def test_url_encode(self): |
---|
337 | ''' |
---|
338 | Test url_encode() |
---|
339 | ''' |
---|
340 | |
---|
341 | encode_test_data = { |
---|
342 | # source: response |
---|
343 | None: None, |
---|
344 | '': '', |
---|
345 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZ': 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', |
---|
346 | 'abcdefghijklmnopqrstuvwxyz': 'abcdefghijklmnopqrstuvwxyz', |
---|
347 | 'Edición': 'Edici%C3%B3n', |
---|
348 | 'A != B': 'A%20%21%3D%20B', |
---|
349 | '@#$%^&*()-={}[]': '%40%23%24%25%5E%26%2A%28%29-%3D%7B%7D%5B%5D', |
---|
350 | '~`|\\:;"\'<>?,./': '~%60%7C%5C%3A%3B%22%27%3C%3E%3F%2C./', |
---|
351 | } |
---|
352 | |
---|
353 | for source, response in encode_test_data.items(): |
---|
354 | self.assertEqual(util.url_encode(value=source), response) |
---|
355 | |
---|
356 | def test_utc_to_local(self): |
---|
357 | ''' |
---|
358 | Test utc_to_local() |
---|
359 | |
---|
360 | Test two "no keyword" cases 1st, then loop through various |
---|
361 | combinations of timestamps. Finally, test the omityear keyword. |
---|
362 | ''' |
---|
363 | |
---|
364 | self.assertIsNone(util.utc_to_local(None), msg='Null utctime') |
---|
365 | self.assertIsNone(util.utc_to_local(''), msg='Empty utctime') |
---|
366 | |
---|
367 | test_hour = '{:2}'.format(24+TEST_UTC_OFFSET) |
---|
368 | |
---|
369 | utc_to_local_data = { |
---|
370 | # time: response |
---|
371 | '20170101 00:01:02': None, |
---|
372 | '2017-01-01 00:01:09': '2016-12-31 {}:01'.format(test_hour), |
---|
373 | '2017-01-01 00:01:22': '2016-12-31 {}:01'.format(test_hour), |
---|
374 | } |
---|
375 | |
---|
376 | for time, response in utc_to_local_data.items(): |
---|
377 | self.assertEqual(util.utc_to_local(utctime=time), response) |
---|
378 | |
---|
379 | utc_to_local_data = { |
---|
380 | # time: response |
---|
381 | None: None, |
---|
382 | '': None, |
---|
383 | '20170101 00:01:02': None, |
---|
384 | '2017-01-01 00:01:09': '2016-12-31 {}:01:09'.format(test_hour), |
---|
385 | '2017-01-01 00:01:22': '2016-12-31 {}:01:22'.format(test_hour), |
---|
386 | |
---|
387 | } |
---|
388 | |
---|
389 | for time, response in utc_to_local_data.items(): |
---|
390 | self.assertEqual(util.utc_to_local(utctime=time, |
---|
391 | omitseconds=False), response) |
---|
392 | |
---|
393 | self.assertEqual(util.utc_to_local(utctime='2017-01-01T00:01:02Z', |
---|
394 | omityear=True, |
---|
395 | omitseconds=False), |
---|
396 | '12-31 {}:01:02'.format(test_hour)) |
---|
397 | |
---|
398 | def test_rec_status_to_string(self): |
---|
399 | ''' |
---|
400 | Test rec_status_to_string() |
---|
401 | ''' |
---|
402 | |
---|
403 | self.assertEqual(util.rec_status_to_string(None), None) |
---|
404 | self.assertEqual(util.rec_status_to_string(backend=None), None) |
---|
405 | |
---|
406 | self.assertIsInstance(REC_STATUS_DATA, dict, msg='None case') |
---|
407 | for rec_status, expect in REC_STATUS_DATA.items(): |
---|
408 | self.assertEqual(util.rec_status_to_string(backend=BACKEND, |
---|
409 | rec_status=rec_status), |
---|
410 | expect) |
---|
411 | |
---|
412 | # Same as previous test, but the strings should be in cache now. The |
---|
413 | # only way to check is to look at the backend log (with -v http turned |
---|
414 | # on.) Watch for: Connection -1 closed. 17 requests were handled from |
---|
415 | # the above. |
---|
416 | |
---|
417 | self.assertIsInstance(REC_STATUS_DATA, dict, msg='None case') |
---|
418 | for rec_status, expect in REC_STATUS_DATA.items(): |
---|
419 | self.assertEqual(util.rec_status_to_string(backend=BACKEND, |
---|
420 | rec_status=rec_status), |
---|
421 | expect) |
---|
422 | |
---|
423 | def test_rec_type_to_string(self): |
---|
424 | ''' |
---|
425 | Test rec_type_to_string() |
---|
426 | ''' |
---|
427 | |
---|
428 | self.assertEqual(util.rec_type_to_string(backend=None, |
---|
429 | rec_type=1), None) |
---|
430 | |
---|
431 | rec_type_data = { |
---|
432 | # rec_type: expect |
---|
433 | # TODO: None case seems odd, expected the same as 0 |
---|
434 | None: 'Override Recording', |
---|
435 | 0: 'Not Recording', |
---|
436 | 1: 'Single Record', |
---|
437 | 3: 'Not Recording', |
---|
438 | 4: 'Record All', |
---|
439 | 5: 'Record Weekly', |
---|
440 | } |
---|
441 | |
---|
442 | for rec_type, expect in rec_type_data.items(): |
---|
443 | self.assertEqual(util.rec_type_to_string(backend=BACKEND, |
---|
444 | rec_type=rec_type), |
---|
445 | expect) |
---|
446 | |
---|
447 | def test_dup_method_to_string(self): |
---|
448 | ''' |
---|
449 | Test dup_method_to_string() |
---|
450 | ''' |
---|
451 | |
---|
452 | # TODO: mythbackend isn't handling this endpoint correctly. |
---|
453 | dup_method_and_response = { |
---|
454 | # method: response |
---|
455 | -5: 'Subtitle and Description', |
---|
456 | -4: 'Subtitle and Description', |
---|
457 | -3: 'Subtitle and Description', |
---|
458 | -2: 'Subtitle and Description', |
---|
459 | -1: 'Subtitle and Description', |
---|
460 | 0: 'Subtitle and Description', |
---|
461 | 1: 'Subtitle and Description', |
---|
462 | 2: 'Subtitle and Description', |
---|
463 | 3: 'Subtitle and Description', |
---|
464 | 4: 'Subtitle and Description', |
---|
465 | 5: 'Subtitle and Description', |
---|
466 | } |
---|
467 | |
---|
468 | for method, response in dup_method_and_response.items(): |
---|
469 | self.assertEqual(util.dup_method_to_string( |
---|
470 | backend=BACKEND, dup_method=method), response) |
---|
471 | |
---|
472 | |
---|
473 | if __name__ == '__main__': |
---|
474 | |
---|
475 | # Can't make this work with unittests: |
---|
476 | # ARGS = process_command_line() |
---|
477 | ARGS = {'debug': False} |
---|
478 | logging.basicConfig(level=logging.DEBUG |
---|
479 | if ARGS['debug'] else logging.CRITICAL) |
---|
480 | logging.getLogger('requests.packages.urllib3').setLevel(logging.ERROR) |
---|
481 | logging.getLogger('urllib3.connectionpool').setLevel(logging.ERROR) |
---|
482 | |
---|
483 | unittest.main(failfast=True, verbosity=2) |
---|
484 | |
---|
485 | # pylint: disable=pointless-string-statement |
---|
486 | ''' |
---|
487 | :!./% --verbose |
---|
488 | :!./% --verbose MythTVServicesAPI |
---|
489 | :!./% --verbose MythTVServicesAPI.test_default_opts |
---|
490 | :!./% --verbose MythTVServicesAPI.test_utilities |
---|
491 | :!./% --verbose MythTVServicesAPI.test_rec_status_to_string |
---|
492 | :!./% --verbose MythTVServicesAPI.test_runtime_exceptions |
---|
493 | :!./% --verbose MythTVServicesAPI.test_utc_to_local |
---|
494 | :!./% --verbose MythTVServicesAPI.test_digest |
---|
495 | ''' |
---|
496 | |
---|
497 | # vim: set expandtab tabstop=4 shiftwidth=4 smartindent colorcolumn=80: |
---|