├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── airplay ├── __init__.py ├── airplay.py ├── cli.py ├── http_server.py ├── tests.py └── vendor │ ├── __init__.py │ └── httpheader.py ├── setup.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # PyInstaller 26 | # Usually these files are written by a python script from a template 27 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage* 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | 43 | # Translations 44 | *.mo 45 | *.pot 46 | 47 | # Django stuff: 48 | *.log 49 | 50 | # Sphinx documentation 51 | docs/_build/ 52 | 53 | # PyBuilder 54 | target/ 55 | 56 | .DS_Store 57 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | env: 3 | - TOXENV=py27 4 | - TOXENV=flake8 5 | install: 6 | - pip install tox 7 | script: 8 | - tox 9 | 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This code is in the public domain unless a more specific license statement 2 | exists in a file. In that case, what the file says is what it is. 3 | (Currently this only applies to the vendor directory) 4 | 5 | If the concept of public domain doesn't exist where you are, and you care 6 | enough, submit a PR with a license change :) 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-airplay [![Build Status](https://travis-ci.org/cnelson/python-airplay.svg?branch=master)](https://travis-ci.org/cnelson/python-airplay) 2 | 3 | A python client for the Video portions of the [AirPlay Protocol](https://nto.github.io/AirPlay.html#video). 4 | 5 | 6 | ## Install 7 | 8 | This package is not on PyPI (yet) so install from this repo: 9 | 10 | $ pip install https://github.com/cnelson/python-airplay/archive/master.zip 11 | 12 | 13 | ## I want to control my AirPlay device from the command line 14 | 15 | Easy! 16 | 17 | # play a remote video file 18 | $ airplay http://clips.vorwaerts-gmbh.de/big_buck_bunny.mp4 19 | 20 | # play a remote video file, but start it half way through 21 | $ airplay -p 0.5 http://clips.vorwaerts-gmbh.de/big_buck_bunny.mp4 22 | 23 | # play a local video file 24 | $ airplay /path/to/some/local/file.mp4 25 | 26 | # or play to a specific device 27 | $ airplay --device 192.0.2.23:7000 http://clips.vorwaerts-gmbh.de/big_buck_bunny.mp4 28 | 29 | $ airplay --help 30 | usage: airplay [-h] [--position POSITION] [--device DEVICE] path 31 | 32 | Playback a local or remote video file via AirPlay. This does not do any on- 33 | the-fly transcoding (yet), so the file must already be suitable for the 34 | AirPlay device. 35 | 36 | positional arguments: 37 | path An absolute path or URL to a video file 38 | 39 | optional arguments: 40 | -h, --help show this help message and exit 41 | --position POSITION, --pos POSITION, -p POSITION 42 | Where to being playback [0.0-1.0] 43 | --device DEVICE, --dev DEVICE, -d DEVICE 44 | Playback video to a specific device 45 | [:()] 46 | 47 | 48 | 49 | ## I want to use this package in my own application 50 | 51 | Awesome! This package is compatible with Python >= 2.7 (including Python 3!) 52 | 53 | # Import the AirPlay class 54 | >>> from airplay import AirPlay 55 | 56 | # If you have zeroconf installed, the find() classmethod will locate devices for you 57 | >>> AirPlay.find(fast=True) 58 | [] 59 | 60 | # or you can manually specify a host/ip and optionally a port 61 | >>> ap = AirPlay('192.0.2.23') 62 | >>> ap = AirPlay('192.0.2.3', 7000) 63 | 64 | # Query the device 65 | >>> ap.server_info() 66 | {'protovers': '1.0', 'deviceid': 'FF:FF:FF:FF:FF:FF', 'features': 955001077751, 'srcvers': '268.1', 'vv': 2, 'osBuildVersion': '13U717', 'model': 'AppleTV5,3', 'macAddress': 'FF:FF:FF:FF:FF:FF'} 67 | 68 | # Play a video 69 | >>> ap.play('http://clips.vorwaerts-gmbh.de/big_buck_bunny.mp4') 70 | True 71 | 72 | # Get detailed playback information 73 | >>> ap.playback_info() 74 | {'duration': 60.095, 'playbackLikelyToKeepUp': True, 'readyToPlayMs': 0, 'rate': 1.0, 'playbackBufferEmpty': True, 'playbackBufferFull': False, 'loadedTimeRanges': [{'start': 0.0, 'duration': 60.095}], 'seekableTimeRanges': [{'start': 0.0, 'duration': 60.095}], 'readyToPlay': 1, 'position': 4.144803403} 75 | 76 | # Get just the playhead position 77 | >>> ap.scrub() 78 | {'duration': 60.095001, 'position': 12.465443} 79 | 80 | # Seek to an absolute position 81 | >>> ap.scrub(30) 82 | {'duration': 60.095001, 'position': 30.0} 83 | 84 | # Pause playback 85 | >>> ap.rate(0.0) 86 | True 87 | 88 | # Resume playback 89 | >>> ap.rate(1.0) 90 | True 91 | 92 | # Stop playback completely 93 | >>> ap.stop() 94 | True 95 | 96 | # Start a webserver to stream a local file to an AirPlay device 97 | >>> ap.serve('/tmp/home_movie.mp4') 98 | 'http://192.0.2.114:51058/home_movie.mp4' 99 | 100 | # Playback the generated URL 101 | >>> ap.play('http://192.0.2.114:51058/home_movie.mp4') 102 | True 103 | 104 | # Read events from a generator as the device emits them 105 | >>> for event in ap.events(): 106 | ... print(event) 107 | ... 108 | {'category': 'video', 'state': 'loading', 'sessionID': 349} 109 | {'category': 'video', 'state': 'paused', 'sessionID': 349} 110 | {'category': 'video', 'state': 'playing', 'params': {'duration': 60.095, 'readyToPlay': 1, 'playbackLikelyToKeepUp': True, 'playbackBufferEmpty': True, 'playbackLikelyToKeepUpTime': 0.0, 'position': 0.0, 'playbackBufferFull': False, 'seekableTimeRanges': [{'duration': 60.095, 'start': 0.0}], 'loadedTimeRanges': [{'duration': 60.095, 'start': 0.0}], 'rate': 1.0}, 'sessionID': 349} 111 | {'category': 'video', 'sessionID': 349, 'type': 'currentItemChanged'} 112 | {'category': 'video', 'state': 'loading', 'sessionID': 349} 113 | {'category': 'video', 'reason': 'ended', 'sessionID': 349, 'state': 'stopped'} 114 | 115 | ## API Documentation 116 | 117 | ### AirPlay(self, host, port=7000, name=None, timeout=5) 118 | 119 | Connect to an AirPlay device 120 | 121 | >>> ap = AirPlay('hostname') 122 | >>> ap 123 | 124 | 125 | #### Arguments 126 | * **host (str):** Hostname or IP address of the device to connect to 127 | * **port (int):** Port to use when connectiong 128 | * **name (str):** Optional. The name of the device 129 | * **timeout (int):** Optional. A timeout for socket operations 130 | 131 | 132 | #### Raises 133 | * **ValueError:** Unable to connect to the device on specified host/port 134 | 135 | ### Class Methods 136 | 137 | ### AirPlay.find(timeout=10, fast=False) 138 | 139 | Discover AirPlay devices using Zeroconf/Bonjour 140 | 141 | >>> AirPlay.find(fast=True) 142 | [] 143 | 144 | 145 | #### Arguments 146 | * **timeout (int):** The number of seconds to wait for responses. If fast is False, then this function will always block for this number of seconds. 147 | 148 | * **fast (bool):** If True, do not wait for timeout to expire return as soon as we've found at least one AirPlay device. 149 | 150 | #### Returns 151 | * **list:** A list of AirPlay objects; one for each AirPlay device found 152 | * **None:** The [zeroconf](https://pypi.python.org/pypi/zeroconf) package is not installed 153 | 154 | 155 | ### Methods 156 | 157 | ### server_info() 158 | 159 | Fetch general information about the AirPlay device 160 | 161 | >>> ap.server_info() 162 | {'protovers': '1.0', 'deviceid': 'FF:FF:FF:FF:FF:FF', 'features': 955001077751, 'srcvers': '268.1', 'vv': 2, 'osBuildVersion': '13U717', 'model': 'AppleTV5,3', 'macAddress': 'FF:FF:FF:FF:FF:FF'} 163 | 164 | #### Returns 165 | * **dict**: key/value pairs that describe the device 166 | 167 | ### play(url, position=0.0) 168 | 169 | #### Arguments 170 | * **url (str):** A URL to video content. It must be accessible by the AirPlay device, and in a format it understands 171 | * **position(float):** Where to begin playback. 0.0 = start, 1.0 = end. 172 | 173 | #### Returns 174 | * **True:** The request for playback was accepted 175 | * **False:** There was an error with the request 176 | 177 | **Note: A result of True does not mean that playback has actually started!** 178 | It only means that the AirPlay device accepted the request and will *attempt* playback. 179 | 180 | 181 | ### rate(rate) 182 | Change the playback rate 183 | 184 | >>> ap.rate(0.0) 185 | True 186 | 187 | #### Arguments 188 | * **rate (float):** The playback rate: 0.0 is paused, 1.0 is playing at the normal speed. 189 | 190 | #### Returns 191 | * **True:** The playback rate was changed 192 | * **False:** The playback rate request was invald 193 | 194 | ### stop() 195 | Stop playback 196 | 197 | >>> ap.stop() 198 | True 199 | 200 | #### Returns 201 | * **True:** Playback was stopped 202 | 203 | ### playback_info() 204 | 205 | Retrieve detailed information about the status of video playback 206 | 207 | >>> ap.playback_info() 208 | {'duration': 60.095, 'playbackLikelyToKeepUp': True, 'readyToPlayMs': 0, 'rate': 1.0, 'playbackBufferEmpty': True, 'playbackBufferFull': False, 'loadedTimeRanges': [{'start': 0.0, 'duration': 60.095}], 'seekableTimeRanges': [{'start': 0.0, 'duration': 60.095}], 'readyToPlay': 1, 'position': 4.144803403} 209 | 210 | #### Returns 211 | * **dict:** key/value pairs describing the playback state 212 | * **False:** Nothing is currently being played 213 | 214 | 215 | ### scrub(position=None) 216 | 217 | Return the current playback position, optionally seek to a specific position 218 | 219 | >>> ap.scrub() 220 | {'duration': 60.095001, 'position': 12.465443} 221 | 222 | >>> ap.scrub(30) 223 | {'duration': 60.095001, 'position': 30.0} 224 | 225 | 226 | 227 | #### Arguments 228 | * **position (float):** If provided, seek to this position 229 | 230 | #### Returns 231 | * **dict:** The current position and duration: {'duration': float(seconds), 'position': float(seconds)} 232 | 233 | ### serve(path) 234 | Start a HTTP server in a new process to serve local content to the AirPlay device 235 | 236 | >>> ap.serve('/tmp/home_movie.mp4') 237 | 'http://192.0.2.114:51058/home_movie.mp4' 238 | 239 | #### Arguments 240 | * **path (str):** An absolute path to a file 241 | 242 | #### Returns 243 | 244 | * **str:** A URL suitable for passing to play() 245 | 246 | 247 | ### events(block=True) 248 | 249 | A generator that yields events as they are emitted by the AirPlay device 250 | 251 | >>> for event in ap.events(): 252 | ... print(event) 253 | ... 254 | {'category': 'video', 'state': 'loading', 'sessionID': 349} 255 | {'category': 'video', 'state': 'paused', 'sessionID': 349} 256 | {'category': 'video', 'state': 'playing', 'params': {'duration': 60.095, 'readyToPlay': 1, 'playbackLikelyToKeepUp': True, 'playbackBufferEmpty': True, 'playbackLikelyToKeepUpTime': 0.0, 'position': 0.0, 'playbackBufferFull': False, 'seekableTimeRanges': [{'duration': 60.095, 'start': 0.0}], 'loadedTimeRanges': [{'duration': 60.095, 'start': 0.0}], 'rate': 1.0}, 'sessionID': 349} 257 | {'category': 'video', 'sessionID': 349, 'type': 'currentItemChanged'} 258 | {'category': 'video', 'state': 'loading', 'sessionID': 349} 259 | {'category': 'video', 'reason': 'ended', 'sessionID': 349, 'state': 'stopped'} 260 | 261 | #### Arguments 262 | 263 | * **block (bool):** If True, this function will block forever, returning events as they become available. If False, this function will return if no events are available 264 | 265 | #### Yields 266 | * **dict:** key/value pairs describing the event emitted by the AirPlay device 267 | 268 | 269 | ## Need more information? 270 | 271 | The [source for the cli script](airplay/cli.py) is a good example of how to use this package. 272 | 273 | The [Unofficial AirPlay Protocol Specification](https://nto.github.io/AirPlay.html#video) documents what data you can send and expect to receive back when using this package. 274 | 275 | -------------------------------------------------------------------------------- /airplay/__init__.py: -------------------------------------------------------------------------------- 1 | from .airplay import AirPlay # NOQA 2 | from .http_server import RangeHTTPServer # NOQA 3 | -------------------------------------------------------------------------------- /airplay/airplay.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import email 3 | import os 4 | import socket 5 | import time 6 | import warnings 7 | 8 | from multiprocessing import Process, Queue 9 | 10 | try: 11 | from BaseHTTPServer import BaseHTTPRequestHandler 12 | except ImportError: 13 | from http.server import BaseHTTPRequestHandler 14 | 15 | try: 16 | from httplib import HTTPResponse 17 | except ImportError: 18 | from http.client import HTTPResponse 19 | 20 | try: 21 | from plistlib import readPlistFromString as plist_loads 22 | except ImportError: 23 | from plistlib import loads as plist_loads 24 | 25 | try: 26 | from Queue import Empty 27 | except ImportError: 28 | from queue import Empty 29 | 30 | try: 31 | from StringIO import StringIO 32 | except ImportError: 33 | from io import BytesIO as StringIO 34 | 35 | try: 36 | from urllib import urlencode 37 | from urllib import pathname2url 38 | except ImportError: 39 | from urllib.parse import urlencode 40 | from urllib.request import pathname2url 41 | 42 | try: 43 | from zeroconf import ServiceBrowser, ServiceStateChange, Zeroconf 44 | except ImportError: 45 | pass 46 | 47 | from .http_server import RangeHTTPServer 48 | 49 | 50 | class FakeSocket(): 51 | """Use StringIO to pretend to be a socket like object that supports makefile()""" 52 | def __init__(self, data): 53 | self._str = StringIO(data) 54 | 55 | def makefile(self, *args, **kwargs): 56 | """Returns the StringIO object. Ignores all arguments""" 57 | return self._str 58 | 59 | 60 | class AirPlayEvent(BaseHTTPRequestHandler): 61 | """Parse an AirPlay event delivered over Reverse HTTP""" 62 | 63 | def do_GET(self): 64 | raise NotImplementedError 65 | 66 | def do_HEAD(self): 67 | raise NotImplementedError 68 | 69 | def do_POST(self): 70 | """Called when a new event has been received""" 71 | 72 | # make sure this is what we expect 73 | if self.path != '/event': 74 | raise RuntimeError('Unexpected path when parsing event: {0}'.format(self.path)) 75 | 76 | # validate our content type 77 | content_type = self.headers.get('content-type', None) 78 | if content_type != 'text/x-apple-plist+xml': 79 | raise RuntimeError('Unexpected Content-Type when parsing event: {0}'.format(content_type)) 80 | 81 | # and the body length 82 | content_length = int(self.headers.get('content-length', 0)) 83 | if content_length == 0: 84 | raise RuntimeError('Received an event with a zero length body.') 85 | 86 | # parse XML plist 87 | self.event = plist_loads(self.rfile.read(content_length)) 88 | 89 | 90 | class AirPlay(object): 91 | """Locate and control devices supporting the AirPlay server protocol for video 92 | This implementation is based on section 4 of https://nto.github.io/AirPlay.html 93 | 94 | For detailed information on most methods and responses, please see the specification. 95 | 96 | """ 97 | RECV_SIZE = 8192 98 | 99 | def __init__(self, host, port=7000, name=None, timeout=5): 100 | """Connect to an AirPlay device on `host`:`port` optionally named `name` 101 | 102 | Args: 103 | host(string): Hostname or IP address of the device to connect to 104 | port(int): Port to use when connectiong 105 | name(string): Optional. The name of the device. 106 | timeout(int): Optional. A timeout for socket operations 107 | 108 | Raises: 109 | ValueError: Unable to connect to the specified host/port 110 | """ 111 | 112 | self.host = host 113 | self.port = port 114 | self.name = name 115 | 116 | # connect the control socket 117 | try: 118 | self.control_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 119 | self.control_socket.settimeout(timeout) 120 | self.control_socket.connect((host, port)) 121 | except socket.error as exc: 122 | raise ValueError("Unable to connect to {0}:{1}: {2}".format(host, port, exc)) 123 | 124 | def _monitor_events(self, event_queue, control_queue): # pragma: no cover 125 | """Connect to `host`:`port` and use reverse HTTP to receive events. 126 | 127 | This function will block until any message is received via `control_queue` 128 | Which a message is received via that queue, the event socket is closed, and this 129 | method will return. 130 | 131 | 132 | Args: 133 | event_queue(Queue): A queue which events will be put into as they are received 134 | control_queue(Queue): If any messages are received on this queue, this function will exit 135 | 136 | Raises: 137 | Any exceptions raised by this method are caught and sent through 138 | the `event_queue` and handled in the main process 139 | """ 140 | 141 | try: 142 | # connect to the host 143 | event_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 144 | event_socket.connect((self.host, self.port)) 145 | 146 | # "upgrade" this connection to Reverse HTTP 147 | raw_request = b"POST /reverse HTTP/1.1\r\nUpgrade: PTTH/1.0\r\nConnection: Upgrade\r\n\r\n" 148 | event_socket.send(raw_request) 149 | 150 | raw_response = event_socket.recv(AirPlay.RECV_SIZE) 151 | resp = HTTPResponse(FakeSocket(raw_response)) 152 | resp.begin() 153 | 154 | # if it was successfully, we should get code 101 'switching protocols' 155 | if resp.status != 101: 156 | raise RuntimeError( 157 | "Unexpected response from AirPlay when setting up event listener.\n" 158 | "Expected: HTTP/1.1 101 Switching Protocols\n\n" 159 | "Sent:\n{0}Received:\n{1}".format(raw_request, raw_response) 160 | ) 161 | 162 | # now we loop forever, receiving events as HTTP POSTs to us 163 | event_socket.settimeout(.1) 164 | 165 | while True: 166 | # see if the parent asked us to exit 167 | try: 168 | control_queue.get(block=False) 169 | event_socket.close() 170 | return 171 | except Empty: 172 | pass 173 | 174 | # receive a request 175 | try: 176 | raw_request = event_socket.recv(AirPlay.RECV_SIZE) 177 | except socket.timeout: 178 | continue 179 | 180 | # parse it 181 | try: 182 | req = AirPlayEvent(FakeSocket(raw_request), event_socket.getpeername(), None) 183 | except RuntimeError as exc: 184 | raise RuntimeError( 185 | "Unexpected request from AirPlay while processing events\n" 186 | "Error: {0}\nReceived:\n{1}".format(exc, raw_request) 187 | ) 188 | 189 | # acknowledge it 190 | event_socket.send(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") 191 | 192 | # skip non-video events 193 | if req.event.get('category', None) != 'video': 194 | continue 195 | 196 | # send the event back to the parent process 197 | event_queue.put(req.event) 198 | 199 | except KeyboardInterrupt: 200 | return 201 | except Exception as exc: 202 | event_queue.put(exc) 203 | return 204 | 205 | def events(self, block=True): 206 | """A generator that produces a list of events from the AirPlay Server 207 | 208 | Args: 209 | block(bool): If true, this function will block until an event is available 210 | If false, the generator will stop when there are no more events 211 | 212 | Yields: 213 | dict: An event provided by the AirPlay server 214 | 215 | """ 216 | # set up our event socket reader in another process if we haven't 217 | # already done so. 218 | try: 219 | getattr(self, 'event_queue') 220 | except AttributeError: 221 | # TODO: switch to Pipe? 222 | self.event_queue = Queue() 223 | self.event_control = Queue() 224 | 225 | self.event_monitor = Process(target=self._monitor_events, args=[self.event_queue, self.event_control]) 226 | self.event_monitor.start() 227 | 228 | # ensure when we shutdown, that the child proess does as well 229 | # this needs to be called _after_ the call to Process.start() 230 | # as multiprocessing also registers atexit handlers, and we want 231 | # ours to run first, Since atexit is LIFO we go last to get run first 232 | atexit.register(lambda: self.event_control.put(True)) 233 | 234 | # loop forever processing events sent to us by the child process 235 | while True: 236 | try: 237 | event = self.event_queue.get(block=block, timeout=None) 238 | # if we were sent an exception, then something went wrong 239 | # in the child process, so reraise it here 240 | if isinstance(event, Exception): 241 | raise event 242 | 243 | # otherwise, it's just an event 244 | yield event 245 | except Empty: 246 | return 247 | 248 | def _command(self, uri, method='GET', body='', **kwargs): 249 | """Makes an HTTP request through to an AirPlay server 250 | 251 | Args: 252 | uri(string): The URI to request 253 | method(string): The HTTP verb to use when requesting `uri`, defaults to GET 254 | body(string): If provided, will be sent witout alteration as the request body. 255 | Content-Length header will be set to len(`body`) 256 | **kwargs: If provided, Will be converted to a query string and appended to `uri` 257 | 258 | Returns: 259 | True: Request returned 200 OK, with no response body 260 | False: Request returned something other than 200 OK, with no response body 261 | 262 | Mixed: The body of the HTTP response 263 | """ 264 | 265 | # generate the request 266 | if len(kwargs): 267 | uri = uri + '?' + urlencode(kwargs) 268 | 269 | request = method + " " + uri + " HTTP/1.1\r\nContent-Length: " + str(len(body)) + "\r\n\r\n" + body 270 | 271 | try: 272 | request = bytes(request, 'UTF-8') 273 | except TypeError: 274 | pass 275 | 276 | # send it 277 | self.control_socket.send(request) 278 | 279 | # parse our response 280 | result = self.control_socket.recv(self.RECV_SIZE) 281 | resp = HTTPResponse(FakeSocket(result)) 282 | resp.begin() 283 | 284 | # if our content length is zero, then return bool based on result code 285 | if int(resp.getheader('content-length', 0)) == 0: 286 | if resp.status == 200: 287 | return True 288 | else: 289 | return False 290 | 291 | # else, parse based on provided content-type 292 | # and return the response body 293 | content_type = resp.getheader('content-type') 294 | 295 | if content_type is None: 296 | raise RuntimeError('Response returned without a content type!') 297 | 298 | if content_type == 'text/parameters': 299 | body = resp.read() 300 | try: 301 | body = str(body, 'UTF-8') 302 | except TypeError: 303 | pass 304 | 305 | return email.message_from_string(body) 306 | 307 | if content_type == 'text/x-apple-plist+xml': 308 | return plist_loads(resp.read()) 309 | 310 | raise RuntimeError('Response received with unknown content-type: {0}'.format(content_type)) 311 | 312 | def get_property(self, *args, **kwargs): 313 | """What it says on the tin""" 314 | raise NotImplementedError('Methods that require binary plists are not supported.') 315 | 316 | def set_property(self, *args, **kwargs): 317 | """What it says on the tin""" 318 | raise NotImplementedError('Methods that require binary plists are not supported.') 319 | 320 | def server_info(self): 321 | """Fetch general informations about the AirPlay server. 322 | 323 | Returns: 324 | dict: key/value pairs that describe the server. 325 | """ 326 | return self._command('/server-info') 327 | 328 | def play(self, url, position=0.0): 329 | """Start video playback. 330 | 331 | Args: 332 | url(string): A URL to video content that the AirPlay server is capable of playing 333 | pos(float): The position in the content to being playback. 0.0 = start, 1.0 = end. 334 | 335 | Returns: 336 | bool: The request was accepted. 337 | 338 | Note: A result of True does not mean that playback will succeed, simply 339 | that the AirPlay server accepted the request and will *attempt* playback 340 | """ 341 | 342 | return self._command( 343 | '/play', 344 | 'POST', 345 | "Content-Location: {0}\nStart-Position: {1}\n\n".format(url, float(position)) 346 | ) 347 | 348 | def rate(self, rate): 349 | """Change the playback rate. 350 | 351 | Args: 352 | rate(float) The playback rate: 0.0 is paused, 1.0 is playing at the normal speed. 353 | 354 | Returns: 355 | True: The playback rate was changed 356 | False: The playback rate requested was invalid 357 | """ 358 | return self._command('/rate', 'POST', value=float(rate)) 359 | 360 | def stop(self): 361 | """Stop playback. 362 | 363 | Note: This does not seem to generate a 'stopped' event from the AirPlay server when called 364 | 365 | Returns: 366 | True: Playback was stopped. 367 | """ 368 | return self._command('/stop', 'POST') 369 | 370 | def playback_info(self): 371 | """Retrieve playback informations such as position, duration, rate, buffering status and more. 372 | 373 | Returns: 374 | dict: key/value pairs describing the playback state 375 | False: Nothing is currently being played 376 | """ 377 | 378 | return self._command('/playback-info') 379 | 380 | def scrub(self, position=None): 381 | """Return the current position or seek to a specific position 382 | 383 | If `position` is not provided returns the current position. If it is 384 | provided, seek to that position and return it. 385 | 386 | Args: 387 | position(float): The position to seek to. 0.0 = start 1.0 = end" 388 | 389 | Returns: 390 | dict: A dict like: {'duration': float(seconds), 'position': float(seconds)} 391 | 392 | """ 393 | args = {} 394 | method = 'GET' 395 | 396 | if position: 397 | method = 'POST' 398 | args['position'] = position 399 | 400 | response = self._command('/scrub', method, **args) 401 | 402 | # When making a POST request to change the scrub position 403 | # The server does not respond with the params 404 | # So we need to make a secord get request after to fetch the data :/ 405 | if position: 406 | return self.scrub() 407 | 408 | # convert the strings we get back to floats (which they should be) 409 | return {kk: float(vv) for (kk, vv) in response.items()} 410 | 411 | def serve(self, path): 412 | """Start a HTTP server to serve local content to the AirPlay device 413 | 414 | Args: 415 | path(str): An absoulte path to a local file to be served. 416 | 417 | Returns: 418 | str: An absolute url to the `path` suitable for passing to play() 419 | """ 420 | 421 | q = Queue() 422 | self._http_server = Process(target=RangeHTTPServer.start, args=(path, self.host, q)) 423 | self._http_server.start() 424 | 425 | atexit.register(lambda: self._http_server.terminate()) 426 | 427 | server_address = (self.control_socket.getsockname()[0], q.get(True)[1]) 428 | 429 | return 'http://{0}:{1}/{2}'.format( 430 | server_address[0], 431 | server_address[1], 432 | pathname2url(os.path.basename(path)) 433 | ) 434 | 435 | @classmethod 436 | def find(cls, timeout=10, fast=False): 437 | """Use Zeroconf/Bonjour to locate AirPlay servers on the local network 438 | 439 | Args: 440 | timeout(int): The number of seconds to wait for responses. 441 | If fast is false, then this function will always block for this number of seconds. 442 | fast(bool): If true, do not wait for timeout to expire, 443 | return as soon as we've found at least one AirPlay server 444 | 445 | Returns: 446 | list: A list of AirPlay() objects; one for each AirPlay server found 447 | 448 | """ 449 | 450 | # this will be our list of devices 451 | devices = [] 452 | 453 | # zeroconf will call this method when a device is found 454 | def on_service_state_change(zeroconf, service_type, name, state_change): 455 | if state_change is ServiceStateChange.Added: 456 | info = zeroconf.get_service_info(service_type, name) 457 | if info is None: 458 | return 459 | 460 | try: 461 | name, _ = name.split('.', 1) 462 | except ValueError: 463 | pass 464 | 465 | devices.append( 466 | cls(socket.inet_ntoa(info.address), info.port, name) 467 | ) 468 | 469 | # search for AirPlay devices 470 | try: 471 | zeroconf = Zeroconf() 472 | browser = ServiceBrowser(zeroconf, "_airplay._tcp.local.", handlers=[on_service_state_change]) # NOQA 473 | except NameError: 474 | warnings.warn( 475 | 'AirPlay.find() requires the zeroconf package but it could not be imported. ' 476 | 'Install it if you wish to use this method. https://pypi.python.org/pypi/zeroconf', 477 | stacklevel=2 478 | ) 479 | return None 480 | 481 | # enforce the timeout 482 | timeout = time.time() + timeout 483 | try: 484 | while time.time() < timeout: 485 | # if they asked us to be quick, bounce as soon as we have one AirPlay 486 | if fast and len(devices): 487 | break 488 | time.sleep(0.05) 489 | except KeyboardInterrupt: # pragma: no cover 490 | pass 491 | finally: 492 | zeroconf.close() 493 | 494 | return devices 495 | -------------------------------------------------------------------------------- /airplay/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import time 4 | 5 | from airplay import AirPlay 6 | 7 | import click 8 | 9 | 10 | def get_airplay_device(hostport): 11 | if hostport is not None: 12 | try: 13 | (host, port) = hostport.split(':', 1) 14 | port = int(port) 15 | except ValueError: 16 | host = hostport 17 | port = 7000 18 | 19 | return AirPlay(host, port) 20 | 21 | devices = AirPlay.find(fast=True) 22 | 23 | if len(devices) == 0: 24 | raise RuntimeError('No AirPlay devices were found. Use --device to manually specify an device.') 25 | elif len(devices) == 1: 26 | return devices[0] 27 | elif len(devices) > 1: 28 | error = "Multiple AirPlay devices were found. Use --device to select a specific one.\n\n" 29 | error += "Available AirPlay devices:\n" 30 | error += "--------------------\n" 31 | for dd in devices: 32 | error += "\t* {0}: {1}:{2}\n".format(dd.name, dd.host, dd.port) 33 | 34 | raise RuntimeError(error) 35 | 36 | 37 | def humanize_seconds(secs): 38 | m, s = divmod(secs, 60) 39 | h, m = divmod(m, 60) 40 | 41 | return "%02d:%02d:%02d" % (h, m, s) 42 | 43 | 44 | def main(): 45 | parser = argparse.ArgumentParser( 46 | description="Playback a local or remote video file via AirPlay. " 47 | "This does not do any on-the-fly transcoding (yet), " 48 | "so the file must already be suitable for the AirPlay device." 49 | ) 50 | 51 | parser.add_argument( 52 | 'path', 53 | help='An absolute path or URL to a video file' 54 | ) 55 | 56 | parser.add_argument( 57 | '--position', 58 | '--pos', 59 | '-p', 60 | default=0.0, 61 | type=float, 62 | help='Where to being playback [0.0-1.0]' 63 | ) 64 | 65 | parser.add_argument( 66 | '--device', 67 | '--dev', 68 | '-d', 69 | default=None, 70 | help='Playback video to a specific device [:()]' 71 | ) 72 | 73 | args = parser.parse_args() 74 | 75 | # connect to the AirPlay device we want to control 76 | try: 77 | ap = get_airplay_device(args.device) 78 | except (ValueError, RuntimeError) as exc: 79 | parser.error(exc) 80 | 81 | duration = 0 82 | position = 0 83 | state = 'loading' 84 | 85 | path = args.path 86 | 87 | # if the url is on our local disk, then we need to spin up a server to start it 88 | if os.path.exists(path): 89 | path = ap.serve(path) 90 | 91 | # play what they asked 92 | ap.play(path, args.position) 93 | 94 | # stay in this loop until we exit 95 | with click.progressbar(length=100, show_eta=False) as bar: 96 | try: 97 | while True: 98 | for ev in ap.events(block=False): 99 | newstate = ev.get('state', None) 100 | 101 | if newstate is None: 102 | continue 103 | 104 | if newstate == 'playing': 105 | duration = ev.get('duration') 106 | position = ev.get('position') 107 | 108 | state = newstate 109 | 110 | if state == 'stopped': 111 | raise KeyboardInterrupt 112 | 113 | bar.label = state.capitalize() 114 | 115 | if state == 'playing': 116 | info = ap.scrub() 117 | duration = info['duration'] 118 | position = info['position'] 119 | 120 | if state in ['playing', 'paused']: 121 | bar.label += ': {0} / {1}'.format( 122 | humanize_seconds(position), 123 | humanize_seconds(duration) 124 | ) 125 | try: 126 | bar.pos = int((position / duration) * 100) 127 | except ZeroDivisionError: 128 | bar.pos = 0 129 | 130 | bar.label = bar.label.ljust(28) 131 | bar.render_progress() 132 | 133 | time.sleep(.5) 134 | 135 | except KeyboardInterrupt: 136 | ap = None 137 | raise SystemExit 138 | 139 | 140 | if __name__ == '__main__': 141 | main() 142 | -------------------------------------------------------------------------------- /airplay/http_server.py: -------------------------------------------------------------------------------- 1 | import os 2 | import posixpath 3 | import socket 4 | import sys 5 | 6 | try: 7 | from BaseHTTPServer import BaseHTTPRequestHandler 8 | except ImportError: 9 | from http.server import BaseHTTPRequestHandler 10 | 11 | try: 12 | import SocketServer 13 | except ImportError: 14 | import socketserver as SocketServer 15 | 16 | try: 17 | from urllib import unquote 18 | except ImportError: 19 | from urllib.parse import unquote 20 | 21 | from .vendor import httpheader 22 | 23 | 24 | # Work around a bug in some versions of Python's SocketServer :( 25 | # http://bugs.python.org/issue14574 26 | def finish_fix(self, *args, **kwargs): # pragma: no cover 27 | try: 28 | if not self.wfile.closed: 29 | self.wfile.flush() 30 | self.wfile.close() 31 | except socket.error: 32 | pass 33 | self.rfile.close() 34 | 35 | SocketServer.StreamRequestHandler.finish = finish_fix 36 | 37 | 38 | class RangeHTTPServer(BaseHTTPRequestHandler): 39 | """This is a simple HTTP server that can be used to serve content to AirPlay devices. 40 | 41 | It supports *single* Range requests which is all (it seems) is required. 42 | """ 43 | @classmethod 44 | def start(cls, filename, allowed_host=None, queue=None): 45 | """Start a SocketServer.TCPServer using this class to handle requests 46 | 47 | Args: 48 | filename(str): An absolute path to a single file to server 49 | Access will only be granted to this file 50 | 51 | allowed_host(str, optional): If provided, only this host will 52 | be allowed to access the server 53 | 54 | queue(Queue.Queue, optional): If provided, the host/port the server 55 | binds to will be put() into this queue 56 | 57 | """ 58 | os.chdir(os.path.dirname(filename)) 59 | 60 | httpd = SocketServer.TCPServer(('', 0), cls) 61 | httpd.allowed_filename = os.path.realpath(filename) 62 | httpd.allowed_host = allowed_host 63 | 64 | if queue: 65 | queue.put(httpd.server_address) 66 | 67 | # BaseHTTPServer likes to log requests to stderr/out 68 | # drop all that nose 69 | with open('/dev/null', 'w') as fh: 70 | sys.stdout = sys.stderr = fh 71 | try: 72 | httpd.serve_forever() 73 | except: # NOQA 74 | pass 75 | 76 | def handle(self): # pragma: no cover 77 | """Handle requests. 78 | 79 | We override this because we need to work around a bug in some 80 | versions of Python's SocketServer :( 81 | 82 | See http://bugs.python.org/issue14574 83 | """ 84 | 85 | self.close_connection = 1 86 | 87 | try: 88 | self.handle_one_request() 89 | except socket.error as exc: 90 | if exc.errno == 32: 91 | pass 92 | 93 | def do_HEAD(self): 94 | """Handle a HEAD request""" 95 | try: 96 | path, stats = self.check_path(self.path) 97 | except ValueError: 98 | return 99 | 100 | self.send_response(200) 101 | self.send_header("Accept-Ranges", "bytes") 102 | self.send_header("Content-Length", stats.st_size) 103 | self.end_headers() 104 | 105 | def do_GET(self): 106 | """Handle a GET request with some support for the Range header""" 107 | try: 108 | path, stats = self.check_path(self.path) 109 | except ValueError: 110 | return 111 | 112 | # assume we are sending the whole file first 113 | ranges = None 114 | first = 0 115 | last = stats.st_size 116 | 117 | # but see if a Range: header tell us differently 118 | try: 119 | ranges = httpheader.parse_range_header(self.headers.get('range', '')) 120 | ranges.fix_to_size(stats.st_size) 121 | ranges.coalesce() 122 | 123 | if not ranges.is_single_range(): 124 | self.send_error(400, "Multiple ranges not supported :(") 125 | return 126 | 127 | first = ranges.range_specs[0].first 128 | last = ranges.range_specs[0].last + 1 129 | except httpheader.ParseError: 130 | pass 131 | except httpheader.RangeUnsatisfiableError: 132 | self.send_error(416, "Requested range not possible") 133 | return 134 | except ValueError: 135 | # this can get raised if the Range request is weird like bytes=2-1 136 | # not sure why this doesn't raise as a ParseError, but whatevs 137 | self.send_error(400, "Bad Request") 138 | return 139 | 140 | try: 141 | with open(path, 'rb') as fh: 142 | if ranges is None: 143 | self.send_response(200) 144 | else: 145 | self.send_response(206) 146 | self.send_header( 147 | "Content-Range", 148 | 'bytes ' + str(first) + '-' + str(last - 1) + '/' + str(stats.st_size) 149 | ) 150 | 151 | self.send_header("Accept-Ranges", "bytes") 152 | self.send_header("Content-Length", last - first) 153 | self.end_headers() 154 | 155 | # send the chunk they asked for 156 | # possibly the whole thing! 157 | buffer_size = 8192 158 | 159 | fh.seek(first, 0) 160 | while buffer_size > 0: 161 | 162 | if first + buffer_size > last: 163 | buffer_size = last - first 164 | try: 165 | self.wfile.write(fh.read(buffer_size)) 166 | except socket.error: 167 | break 168 | 169 | first = first + buffer_size 170 | except EnvironmentError: 171 | self.send_error(500, "Internal Server Error") 172 | return 173 | 174 | def check_path(self, path): 175 | """Verify that the client and server are allowed to access `path` 176 | 177 | Args: 178 | path(str): The path from an HTTP rqeuest, it will be joined to os.getcwd() 179 | 180 | Returns: 181 | (str, stats): An abosolute path to the file on disk, and the result of os.stat() 182 | 183 | Raises: 184 | ValueError: The path could not be accessed (exception will say why) 185 | """ 186 | 187 | # get full path to file requested 188 | path = posixpath.normpath(unquote(path)) 189 | path = os.path.join(os.getcwd(), path.lstrip('/')) 190 | 191 | # if we have an allowed host, then only allow access from it 192 | if self.server.allowed_host and self.client_address[0] != self.server.allowed_host: 193 | self.send_error(400, "Bad Request") 194 | raise ValueError('Client is not allowed') 195 | 196 | # don't do directory indexing 197 | if os.path.isdir(path): 198 | self.send_error(400, "Bad Request") 199 | raise ValueError("Requested path is a directory") 200 | 201 | # if they try to request something else, don't serve it 202 | if path != self.server.allowed_filename: 203 | self.send_error(400, "Bad Request") 204 | raise ValueError("Requested path was not in the allowed list") 205 | 206 | # make sure we can stat and open the file 207 | try: 208 | stats = os.stat(path) 209 | fh = open(path, 'rb') 210 | except (EnvironmentError) as exc: 211 | self.send_error(500, "Internal Server Error") 212 | raise ValueError("Unable to access the path: {0}".format(exc)) 213 | finally: 214 | try: 215 | fh.close() 216 | except NameError: 217 | pass 218 | 219 | return path, stats 220 | -------------------------------------------------------------------------------- /airplay/tests.py: -------------------------------------------------------------------------------- 1 | import email 2 | import os 3 | import socket 4 | import tempfile 5 | import time 6 | import unittest 7 | import warnings 8 | 9 | try: 10 | from urllib2 import Request 11 | from urllib2 import urlopen 12 | from urllib2 import URLError 13 | except ImportError: 14 | from urllib.request import Request 15 | from urllib.request import urlopen 16 | from urllib.error import URLError 17 | 18 | try: 19 | from mock import call, patch, Mock 20 | except ImportError: 21 | from unittest.mock import call, patch, Mock 22 | 23 | from zeroconf import ServiceStateChange 24 | 25 | from .airplay import FakeSocket, AirPlayEvent, AirPlay, RangeHTTPServer 26 | 27 | 28 | class TestFakeSocket(unittest.TestCase): 29 | def test_socket(self): 30 | """When using the FakeSocket we get the same data out that we put in""" 31 | 32 | f = FakeSocket(b"foo") 33 | 34 | assert f.makefile().read() == b"foo" 35 | 36 | 37 | class TestAirPlayEvent(unittest.TestCase): 38 | 39 | # TODO: Move these fixtures to external files 40 | GET_REQUEST = b"GET /event HTTP/1.1\r\nConnection: close\r\n\r\n" 41 | HEAD_REQUEST = b"HEAD /event HTTP/1.1\r\nConnection: close\r\n\r\n" 42 | 43 | BAD_PATH_REQUEST = b"POST /foo HTTP/1.1\r\nConnection: close\r\n\r\n" 44 | 45 | NO_CONTENT_TYPE_REQUEST = b"POST /event HTTP/1.1\r\nConnection: close\r\n\r\n" 46 | BAD_CONTENT_TYPE_REQUEST = b"POST /event HTTP/1.1\r\nConnection: close\r\nContent-Type: foo\r\n\r\n" 47 | 48 | NO_CONTENT_LENGTH_REQUEST = b"POST /event HTTP/1.1\r\nConnection: close\r\nContent-Type: text/x-apple-plist+xml\r\n\r\n" # NOQA 49 | BAD_CONTENT_LENGTH_REQUEST = b"POST /event HTTP/1.1\r\nConnection: close\r\nContent-Type: text/x-apple-plist+xml\r\nContent-Length: 0\r\n\r\n" # NOQA 50 | GOOD_REQUEST = b"""POST /event HTTP/1.1\r\nConnection: close\r\nContent-Type: text/x-apple-plist+xml\r\nContent-Length: 227\r\n\r\n\n\n\n\n\ttest\n\tfoo\n\n""" # NOQA 51 | 52 | def parse_request(self, req): 53 | return AirPlayEvent(FakeSocket(req), ('192.0.2.23', 916), None) 54 | 55 | def test_bad_methods(self): 56 | """Only POST requests are supported""" 57 | 58 | self.assertRaises(NotImplementedError, self.parse_request, self.GET_REQUEST) 59 | self.assertRaises(NotImplementedError, self.parse_request, self.HEAD_REQUEST) 60 | 61 | def test_bad_path(self): 62 | """Requests not made to /event raise RuntimeError""" 63 | 64 | self.assertRaises(RuntimeError, self.parse_request, self.BAD_PATH_REQUEST) 65 | 66 | def test_bad_content_type(self): 67 | """Requests with invalid content-types raise RuntimeError""" 68 | 69 | self.assertRaises(RuntimeError, self.parse_request, self.NO_CONTENT_TYPE_REQUEST) 70 | self.assertRaises(RuntimeError, self.parse_request, self.BAD_CONTENT_TYPE_REQUEST) 71 | 72 | def test_bad_content_length(self): 73 | """Requests with invalid content-length raise RuntimeError""" 74 | 75 | self.assertRaises(RuntimeError, self.parse_request, self.NO_CONTENT_LENGTH_REQUEST) 76 | self.assertRaises(RuntimeError, self.parse_request, self.BAD_CONTENT_LENGTH_REQUEST) 77 | 78 | def test_good_request(self): 79 | """Requests with valid plists are parsed correctly""" 80 | 81 | # parse our simple request wihich has a plist that defines 'test' == 'foo' 82 | req = self.parse_request(self.GOOD_REQUEST) 83 | 84 | # make sure we parsed it correctly 85 | assert req.event['test'] == 'foo' 86 | 87 | 88 | class TestAirPlayEventMonitor(unittest.TestCase): 89 | # mock socket to return our test object 90 | # send => drop 91 | # recv => return fixture response 101, then whatever 92 | 93 | # insepect queues for results 94 | 95 | @patch('airplay.airplay.socket', new_callable=lambda: MockSocket) 96 | def setUp(self, mock): 97 | 98 | mock.sock = MockSocket() 99 | mock.sock.recv_data = """HTTP/1.1 501 Not Implemented\r\nContent-Length: 0\r\n\r\n""" 100 | 101 | self.ap = AirPlay('192.0.2.23', 916, 'test') 102 | 103 | @patch('airplay.airplay.socket', new_callable=lambda: MockSocket) 104 | def test_event_bad_upgrade(self, mock): 105 | """When 101 response is not returned on the event socket, RuntimerError is raised""" 106 | 107 | def go(): 108 | list(self.ap.events(block=True)) 109 | 110 | self.assertRaises(RuntimeError, go) 111 | 112 | @patch('airplay.airplay.socket', new_callable=lambda: MockSocket) 113 | def test_event_socket_closed_control(self, mock): 114 | """When a message is received on the control queue, the socket is closed""" 115 | 116 | # start the event listener 117 | list(self.ap.events(block=False)) 118 | 119 | # ensure it's running 120 | assert self.ap.event_monitor.is_alive() 121 | 122 | # tell it to die 123 | self.ap.event_control.put(True) 124 | 125 | # wait for timeout to occur 126 | time.sleep(1) 127 | 128 | # it should be dead 129 | assert self.ap.event_monitor.is_alive() is False 130 | 131 | @patch('airplay.airplay.socket', new_callable=lambda: MockSocket) 132 | def test_bad_event(self, mock): 133 | """When an unparseable event is received, RuntimeError is raised""" 134 | 135 | mock.sock.recv_data = [ 136 | """HTTP/1.1 101 Switching Protocols\r\nContent-Length: 0\r\n\r\n""", 137 | """POST /event HTTP/1.1\r\nContent-Type: text/plain\r\nContent-Length: 2\r\n\r\nhi""" 138 | ] 139 | 140 | def go(): 141 | list(self.ap.events(block=True)) 142 | 143 | self.assertRaises(RuntimeError, go) 144 | 145 | @patch('airplay.airplay.socket', new_callable=lambda: MockSocket) 146 | def test_non_video_event(self, mock): 147 | """Events that are not video related are not forwarded to the queue""" 148 | 149 | mock.sock.recv_data = [ 150 | """HTTP/1.1 101 Switching Protocols\r\nContent-Length: 0\r\n\r\n""", 151 | """POST /event HTTP/1.1\r\nContent-Type: text/x-apple-plist+xml\r\nContent-Length: 303\r\n\r\ncategoryphotosessionID13statepaused""" # NOQA 152 | ] 153 | 154 | gen = self.ap.events(block=True) 155 | 156 | def go(): 157 | try: 158 | next(gen) 159 | except TypeError: 160 | raise socket.timeout 161 | 162 | # TODO: Fix this whole fucking test, it's gross 163 | # note: this is not the real behaivor of the code, but a by product 164 | # of their being no more events in our MockSocket mock 165 | # so this error is raised 166 | # the TypeError above, I _think_ is called by this: 167 | # http://stackoverflow.com/questions/18163697/exception-typeerror-warning-sometimes-shown-sometimes-not-when-using-throw-meth 168 | # but haven't debugged fully 169 | self.assertRaises(socket.timeout, go) 170 | 171 | @patch('airplay.airplay.socket', new_callable=lambda: MockSocket) 172 | def test_good_event(self, mock): 173 | """When we receive a properly formatted video event, we forward it to the queue""" 174 | 175 | mock.sock.recv_data = [ 176 | """HTTP/1.1 101 Switching Protocols\r\nContent-Length: 0\r\n\r\n""", 177 | """POST /event HTTP/1.1\r\nContent-Type: text/x-apple-plist+xml\r\nContent-Length: 303\r\n\r\ncategoryvideosessionID13statepaused""" # NOQA 178 | ] 179 | 180 | gen = self.ap.events(block=True) 181 | 182 | ev = next(gen) 183 | 184 | assert ev['category'] == 'video' 185 | assert ev['state'] == 'paused' 186 | 187 | @patch('airplay.airplay.socket', new_callable=lambda: MockSocket) 188 | def test_event_queue_empty(self, mock): 189 | """The generator stops when there are no more events""" 190 | 191 | # no events in this list 192 | mock.sock.recv_data = [ 193 | """HTTP/1.1 101 Switching Protocols\r\nContent-Length: 0\r\n\r\n""" 194 | ] 195 | 196 | gen = self.ap.events(block=False) 197 | 198 | def go(): 199 | next(gen) 200 | 201 | self.assertRaises(StopIteration, go) 202 | 203 | 204 | class TestAirPlayControls(unittest.TestCase): 205 | @patch('airplay.airplay.socket', new_callable=lambda: MockSocket) 206 | def setUp(self, mock): 207 | 208 | mock.sock = MockSocket() 209 | mock.sock.recv_data = """HTTP/1.1 501 Not Implemented\r\nContent-Length: 0\r\n\r\n""" 210 | 211 | self.ap = AirPlay('192.0.2.23', 916, 'test') 212 | 213 | assert self.ap.name == 'test' 214 | 215 | @patch('airplay.airplay.socket.socket', side_effect=socket.error) 216 | def test_bad_hostport(self, mock): 217 | """ValueError is raised when socket setup/connect fails""" 218 | def go(): 219 | AirPlay('192.0.2.23', 916, 'test') 220 | 221 | self.assertRaises(ValueError, go) 222 | 223 | def test_uri_only(self): 224 | """When called _command with just an uri, a GET request is generated""" 225 | 226 | self.ap._command('/foo') 227 | 228 | assert self.ap.control_socket.send_data.startswith(b'GET /foo') 229 | 230 | def test_uri_kwargs(self): 231 | """When _command is called with kwargs they are converted to a query string""" 232 | 233 | self.ap._command('/foo', bar='bork') 234 | 235 | assert self.ap.control_socket.send_data.startswith(b'GET /foo?bar=bork') 236 | 237 | def test_method(self): 238 | """When a method is provided, it is used in the generated request""" 239 | self.ap._command('/foo', method='POST') 240 | 241 | assert self.ap.control_socket.send_data.startswith(b'POST /foo') 242 | 243 | def test_body(self): 244 | """When a body is provided, it is included in the request with an appropriate content-length""" 245 | body = "lol some data" 246 | 247 | self.ap._command('/foo', method='POST', body=body) 248 | 249 | try: 250 | body = bytes(body, 'UTF-8') 251 | except TypeError: 252 | pass 253 | 254 | assert body == self.ap.control_socket.send_data[len(body) * -1:] 255 | 256 | assert "Content-Length: {0}".format(len(body)) in str(self.ap.control_socket.send_data) 257 | 258 | def test_no_body(self): 259 | """When no body is provided, we don't send one and content-length is 0""" 260 | self.ap._command('/foo', method='POST') 261 | 262 | assert b'Content-Length: 0' in self.ap.control_socket.send_data 263 | assert self.ap.control_socket.send_data.endswith(b"\r\n\r\n") 264 | 265 | def test_no_body_response_200(self): 266 | """When we receive a 200 response with no body, we return True""" 267 | 268 | self.ap.control_socket.recv_data = """HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n""" 269 | 270 | assert self.ap._command('/foo') is True 271 | 272 | def test_no_body_response_400(self): 273 | """When we receive a non-200 response with no body, we return False""" 274 | 275 | assert self.ap._command('/foo') is False 276 | 277 | def test_body_no_content_type(self): 278 | """RutimeError is raised if we receive a body with no Content-Type""" 279 | 280 | self.ap.control_socket.recv_data = """HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nhi""" 281 | 282 | def go(): 283 | self.ap._command('/foo') 284 | 285 | self.assertRaises(RuntimeError, go) 286 | 287 | def test_body_content_type_param(self): 288 | """When Content-Type is text/parameters we return the parsed body""" 289 | 290 | self.ap.control_socket.recv_data = """HTTP/1.1 200 OK\r\nContent-Type: text/parameters\r\nContent-Length: 40\r\n\r\nduration: 83.124794\r\nposition: 14.467000""" # NOQA 291 | 292 | res = self.ap._command('/foo') 293 | 294 | # note: this data is returned as strings, the scrub() 295 | # method will convert to float, not _command 296 | assert res['duration'] == '83.124794' 297 | assert res['position'] == '14.467000' 298 | 299 | def test_body_content_type_plist(self): 300 | """When Content-Type is text/parameters we return the parsed body""" 301 | 302 | self.ap.control_socket.recv_data = """HTTP/1.1 200 OK\r\nContent-Type: text/x-apple-plist+xml\r\nContent-Length: 219\r\n\r\nduration 1801""" # NOQA 303 | 304 | res = self.ap._command('/foo') 305 | 306 | # note: this is a plist so the data is converted by plistlib immediatelyr 307 | # unlike the text/parameters version above 308 | assert res['duration'] == 1801.0 309 | 310 | def test_body_content_type_unknown(self): 311 | """RuntimeError is raised if we receive an unknown content-type""" 312 | 313 | self.ap.control_socket.recv_data = """HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\n\r\nhi""" # NOQA 314 | 315 | def go(): 316 | self.ap._command('/foo') 317 | 318 | self.assertRaises(RuntimeError, go) 319 | 320 | # these just all stubout _command and ensure it was called with the correct 321 | # parameters 322 | def test_get_property(self): 323 | """Get Property isn't implemented""" 324 | def go(): 325 | self.ap.get_property() 326 | 327 | self.assertRaises(NotImplementedError, go) 328 | 329 | def test_set_property(self): 330 | """Set Property isn't implemented""" 331 | def go(): 332 | self.ap.set_property() 333 | 334 | self.assertRaises(NotImplementedError, go) 335 | 336 | def test_server_info(self): 337 | """When server_info is called we pass the appropriate params""" 338 | self.ap._command = Mock() 339 | 340 | self.ap.server_info() 341 | 342 | self.ap._command.assert_called_with('/server-info') 343 | 344 | def test_play_no_pos(self): 345 | """When play with no position is called, we start at 0""" 346 | self.ap._command = Mock() 347 | 348 | expected_body = "Content-Location: foo\nStart-Position: 0.0\n\n" 349 | 350 | self.ap.play('foo') 351 | 352 | self.ap._command.assert_called_with('/play', 'POST', expected_body) 353 | 354 | def test_play_pos(self): 355 | """When play with position is called, we send it along""" 356 | self.ap._command = Mock() 357 | 358 | expected_body = "Content-Location: foo\nStart-Position: 0.5\n\n" 359 | 360 | self.ap.play('foo', position=0.5) 361 | 362 | self.ap._command.assert_called_with('/play', 'POST', expected_body) 363 | 364 | def test_rate(self): 365 | """When rate is called it's sent as a query param""" 366 | self.ap._command = Mock() 367 | 368 | self.ap.rate(0.3) 369 | 370 | self.ap._command.assert_called_with('/rate', 'POST', value=0.3) 371 | 372 | def test_stop(self): 373 | """Stop is sent as a POST""" 374 | self.ap._command = Mock() 375 | 376 | self.ap.stop() 377 | 378 | self.ap._command.assert_called_with('/stop', 'POST') 379 | 380 | def test_playback_info(self): 381 | """Playback Info is sent as a GET""" 382 | self.ap._command = Mock() 383 | 384 | self.ap.playback_info() 385 | 386 | self.ap._command.assert_called_with('/playback-info') 387 | 388 | def test_scrub_no_pos(self): 389 | """Scrub is sent as a GET when no position is provided and the return values are converted to floats""" 390 | 391 | rv = email.message_from_string("""duration: 83.124794\r\nposition: 14.467000""") 392 | 393 | self.ap._command = Mock(return_value=rv) 394 | 395 | result = self.ap.scrub() 396 | 397 | self.ap._command.assert_called_with('/scrub', 'GET') 398 | 399 | assert result['duration'] == 83.124794 400 | assert result['position'] == 14.467000 401 | 402 | def test_scrub_pos(self): 403 | """Scrub is sent as a POST when position is provided""" 404 | 405 | rv = email.message_from_string("""duration: 83.124794\r\nposition: 14.467000""") 406 | 407 | self.ap._command = Mock(return_value=rv) 408 | 409 | self.ap.scrub(91.6) 410 | 411 | calls = [call('/scrub', 'POST', position=91.6), call('/scrub', 'GET')] 412 | 413 | self.ap._command.assert_has_calls(calls) 414 | 415 | 416 | class TestAirPlayDiscovery(unittest.TestCase): 417 | @patch('airplay.airplay.socket.socket') 418 | @patch('airplay.airplay.ServiceBrowser', new_callable=lambda: FakeServiceBrowser) 419 | @patch('airplay.airplay.Zeroconf', new_callable=lambda: FakeZeroconf) 420 | def test_timeout(self, zc, sb, sock): 421 | """When fast=False, find() always waits for the timeout to expire""" 422 | 423 | sb.name = 'test-device.foo.bar' 424 | sb.info = zc.info = Mock(address=socket.inet_aton('192.0.2.23'), port=916) 425 | 426 | start = time.time() 427 | devices = AirPlay.find(timeout=2, fast=False) 428 | assert time.time() - start > 2 429 | 430 | assert isinstance(devices[0], AirPlay) 431 | assert devices[0].name == 'test-device' 432 | assert devices[0].host == socket.inet_ntoa(zc.info.address) 433 | assert devices[0].port == zc.info.port 434 | 435 | @patch('airplay.airplay.socket.socket') 436 | @patch('airplay.airplay.ServiceBrowser', new_callable=lambda: FakeServiceBrowser) 437 | @patch('airplay.airplay.Zeroconf', new_callable=lambda: FakeZeroconf) 438 | def test_fast_results(self, zc, sb, sock): 439 | """When fast=True find() returns as soon as there is a result""" 440 | 441 | sb.name = 'test-short' 442 | sb.info = zc.info = Mock(address=socket.inet_aton('192.0.2.23'), port=916) 443 | 444 | start = time.time() 445 | devices = AirPlay.find(timeout=2, fast=True) 446 | 447 | assert time.time() - start < 2 448 | 449 | assert devices[0].name == sb.name 450 | assert devices[0].host == socket.inet_ntoa(zc.info.address) 451 | assert devices[0].port == zc.info.port 452 | 453 | @patch('airplay.airplay.socket.socket') 454 | @patch('airplay.airplay.ServiceBrowser', new_callable=lambda: FakeServiceBrowser) 455 | @patch('airplay.airplay.Zeroconf') 456 | def test_no_info(self, zc, sb, sock): 457 | """If zeroconf doesn't return info on the service, we don't store it""" 458 | 459 | sb.info = None 460 | 461 | start = time.time() 462 | devices = AirPlay.find(timeout=2, fast=True) 463 | assert time.time() - start > 2 464 | 465 | assert len(devices) == 0 466 | 467 | 468 | class TestRangeHTTPServerACL(unittest.TestCase): 469 | def setUp(self): 470 | 471 | self.data = b'abcdefghijklmnopqrstuvwxyz' * 1024 472 | fd, path = tempfile.mkstemp() 473 | os.write(fd, self.data) 474 | os.close(fd) 475 | self.testfile = path 476 | 477 | os.chdir(os.path.dirname(self.testfile)) 478 | 479 | self.path = '/' + os.path.basename(self.testfile) 480 | 481 | self.server = Mock() 482 | 483 | self.client = ('127.0.0.1', 9160) 484 | 485 | def fake_request(self, path): 486 | self.http = RangeHTTPServer(FakeSocket(b''), self.client, self.server) 487 | self.http.handle = lambda x: None 488 | self.http.send_error = Mock() 489 | 490 | return self.http.check_path(path) 491 | 492 | def tearDown(self): 493 | try: 494 | os.remove(self.testfile) 495 | except OSError: 496 | pass 497 | 498 | def test_allowed_host(self): 499 | """ValueError is raised if an unallowed host attempts access""" 500 | 501 | self.server = Mock(allowed_host='192.0.2.99') 502 | 503 | self.assertRaises(ValueError, self.fake_request, self.path) 504 | 505 | self.http.send_error.assert_called_with(400, 'Bad Request') 506 | 507 | def test_no_directories(self): 508 | """ValueError is raised if directory access is attempted""" 509 | 510 | self.server = Mock(allowed_host='127.0.0.1') 511 | 512 | self.assertRaises(ValueError, self.fake_request, '/') 513 | 514 | self.http.send_error.assert_called_with(400, 'Bad Request') 515 | 516 | def test_allowed_filename(self): 517 | """ValueError is raised if any other files are requested""" 518 | 519 | self.server = Mock( 520 | allowed_filename=os.path.realpath(self.testfile), 521 | allowed_host='127.0.0.1' 522 | ) 523 | 524 | result = self.fake_request(self.path) 525 | 526 | assert result[0] == self.server.allowed_filename 527 | 528 | self.assertRaises(ValueError, self.fake_request, '/../../../../../.././etc/passwd') 529 | self.http.send_error.assert_called_with(400, 'Bad Request') 530 | 531 | self.assertRaises(ValueError, self.fake_request, '/foo') 532 | self.http.send_error.assert_called_with(400, 'Bad Request') 533 | 534 | def test_file_open(self): 535 | """ValueError is raised if we cannot open or stat the file""" 536 | 537 | self.server = Mock( 538 | allowed_filename=os.path.realpath(self.testfile), 539 | allowed_host='127.0.0.1' 540 | ) 541 | 542 | # can't open file 543 | os.chmod(self.testfile, 0000) 544 | self.assertRaises(ValueError, self.fake_request, self.path) 545 | self.http.send_error.assert_called_with(500, 'Internal Server Error') 546 | 547 | # file doesn't exist 548 | os.remove(self.testfile) 549 | self.assertRaises(ValueError, self.fake_request, self.path) 550 | self.http.send_error.assert_called_with(500, 'Internal Server Error') 551 | 552 | 553 | class TestRangeHTTPServerOSError(unittest.TestCase): 554 | @patch('airplay.airplay.socket', new_callable=lambda: MockSocket) 555 | def setUp(self, mock): 556 | 557 | mock.sock = MockSocket() 558 | mock.sock.recv_data = """HTTP/1.1 501 Not Implemented\r\nContent-Length: 0\r\n\r\n""" 559 | 560 | self.data = b'abcdefghijklmnopqrstuvwxyz' * 1024 561 | fd, path = tempfile.mkstemp() 562 | os.write(fd, self.data) 563 | os.close(fd) 564 | self.testfile = path 565 | 566 | # patch our check function to return the expected data 567 | # but simulate the file disappaearing between the check 568 | # and when we re-open it for sending 569 | def no_check_path(self, *args, **kwargs): 570 | stats = os.stat(path) 571 | os.remove(path) 572 | 573 | return path, stats 574 | 575 | with patch('airplay.airplay.RangeHTTPServer.check_path', side_effect=no_check_path): 576 | self.ap = AirPlay('127.0.0.1', 916, 'test') 577 | self.test_url = self.ap.serve(path) 578 | 579 | assert self.test_url.startswith('http://127.0.0.1') 580 | 581 | def tearDown(self): 582 | try: 583 | os.remove(self.testfile) 584 | except OSError: 585 | pass 586 | 587 | def test_os_error(self): 588 | """Problems reading the file after check return HTTP 500""" 589 | # mock out check_path to just return path 590 | # call path with an invalid file 591 | 592 | request = Request(self.test_url) 593 | 594 | error = None 595 | try: 596 | urlopen(request) 597 | except URLError as exc: 598 | error = exc 599 | except: 600 | pass 601 | 602 | assert error.code == 500 603 | 604 | 605 | class TestLazyLoading(unittest.TestCase): 606 | @patch('airplay.airplay.socket', new_callable=lambda: MockSocket) 607 | def setUp(self, mock): 608 | 609 | mock.sock = MockSocket() 610 | mock.sock.recv_data = """HTTP/1.1 501 Not Implemented\r\nContent-Length: 0\r\n\r\n""" 611 | 612 | try: 613 | import airplay.airplay as airplay 614 | except ImportError: 615 | import airplay 616 | 617 | # Is there a better way to test Lazy Imports than this? 618 | # mucking around in __dict__ feels gross, but nothing else seemed to work :/ 619 | # tried mock.patch.dict with sys.modules 620 | # tried mock.patch.dict with airplay.__dict__ 621 | # tried patch, but it doesn't have a delete option 622 | # needs to work with python 2 and python 3 623 | 624 | self.apnuke = { 625 | 'Zeroconf': None, 626 | 'ServiceBrowser': None 627 | } 628 | 629 | for thing in self.apnuke.keys(): 630 | try: 631 | self.apnuke[thing] = airplay.__dict__[thing] 632 | del airplay.__dict__[thing] 633 | except KeyError: 634 | pass 635 | 636 | self.ap = airplay.AirPlay('127.0.0.1', 916, 'test') 637 | 638 | def tearDown(self): 639 | try: 640 | import airplay.airplay as airplay 641 | except ImportError: 642 | import airplay 643 | 644 | for kk, vv in self.apnuke.items(): 645 | if vv is not None: 646 | airplay.__dict__[kk] = vv 647 | 648 | def test_find_no_zeroconf(self): 649 | """None is returned from find() if we dont have zeroconf installed""" 650 | 651 | with warnings.catch_warnings(): 652 | warnings.simplefilter("ignore") 653 | assert self.ap.find() is None 654 | 655 | 656 | class TestRangeHTTPServer(unittest.TestCase): 657 | @patch('airplay.airplay.socket', new_callable=lambda: MockSocket) 658 | def setUp(self, mock): 659 | 660 | mock.sock = MockSocket() 661 | mock.sock.recv_data = """HTTP/1.1 501 Not Implemented\r\nContent-Length: 0\r\n\r\n""" 662 | 663 | self.ap = AirPlay('127.0.0.1', 916, 'test') 664 | 665 | self.data = b'abcdefghijklmnopqrstuvwxyz' * 1024 666 | 667 | fd, path = tempfile.mkstemp() 668 | os.write(fd, self.data) 669 | os.close(fd) 670 | 671 | self.test_url = self.ap.serve(path) 672 | 673 | assert self.test_url.startswith('http://127.0.0.1') 674 | 675 | self.testfile = path 676 | 677 | def tearDown(self): 678 | os.remove(self.testfile) 679 | 680 | def test_no_multiple_ranges(self): 681 | """Multiple Ranges are not supported""" 682 | 683 | request = Request(self.test_url) 684 | request.add_header('range', 'bytes=1-4,9-90') 685 | 686 | error = None 687 | try: 688 | urlopen(request) 689 | except URLError as exc: 690 | error = exc 691 | 692 | assert error.code == 400 693 | 694 | def test_unsatisfiable_range(self): 695 | """Range requests out of bounds return HTTP 416""" 696 | 697 | request = Request(self.test_url) 698 | 699 | # make our request start past the end of our file 700 | first = len(self.data) + 1024 701 | 702 | request.add_header('range', 'bytes={0}-'.format(first)) 703 | 704 | error = None 705 | try: 706 | urlopen(request) 707 | except URLError as exc: 708 | error = exc 709 | 710 | assert error.code == 416 711 | 712 | def test_bad_range(self): 713 | """Malformed (not empty) range requests return HTTP 400""" 714 | 715 | request = Request(self.test_url) 716 | request.add_header('range', 'bytes=2-1') 717 | 718 | error = None 719 | try: 720 | urlopen(request) 721 | except URLError as exc: 722 | error = exc 723 | 724 | assert error.code == 400 725 | 726 | def test_full_get(self): 727 | """When we make a GET request with no Range header, the entire file is returned""" 728 | request = Request(self.test_url) 729 | 730 | assert self.data == urlopen(request).read() 731 | 732 | def test_range_get(self): 733 | """When we make a GET request with a Range header, the proper chunk is returned with appropriate headers""" 734 | request = Request(self.test_url) 735 | request.add_header('range', 'bytes=1-4') 736 | 737 | response = urlopen(request) 738 | msg = response.info() 739 | 740 | assert int(msg['content-length']) == 4 741 | assert msg['content-range'] == "bytes 1-4/{0}".format(len(self.data)) 742 | 743 | assert self.data[1:5] == response.read() 744 | 745 | def test_head(self): 746 | """When we make a HEAD request, no body is returned""" 747 | request = Request(self.test_url) 748 | request.get_method = lambda: 'HEAD' 749 | 750 | response = urlopen(request) 751 | msg = response.info() 752 | 753 | # HEAD should return no body 754 | assert response.read() == b'' 755 | 756 | # we should get the proper content-header back 757 | assert int(msg['content-length']) == len(self.data) 758 | 759 | 760 | class FakeZeroconf(object): 761 | def __init__(self, info=None): 762 | self.info = info 763 | 764 | def get_service_info(self, *args, **kwargs): 765 | return self.info 766 | 767 | def close(self): 768 | pass 769 | 770 | 771 | class FakeServiceBrowser(object): 772 | name = 'fake-service.local' 773 | info = None 774 | 775 | def __init__(self, *args, **kwargs): 776 | self.handler = kwargs.get('handlers')[0] 777 | self.handler(FakeZeroconf(self.info), args[1], self.name, ServiceStateChange.Added) 778 | 779 | 780 | class MockSocket(object): 781 | sock = None 782 | timeout = None 783 | error = socket.error 784 | AF_INET = socket.AF_INET 785 | SOCK_STREAM = socket.SOCK_STREAM 786 | 787 | def __init__(self, *args, **kwargs): 788 | self.send_data = '' 789 | self.recv_data = '' 790 | 791 | def recv(self, *args, **kwargs): 792 | try: 793 | basestring 794 | except NameError: 795 | basestring = str 796 | 797 | if isinstance(self.recv_data, basestring): 798 | data = self.recv_data 799 | else: 800 | try: 801 | data = self.recv_data.pop(0) 802 | except IndexError: 803 | raise socket.timeout 804 | 805 | try: 806 | return bytes(data, 'UTF-8') 807 | except TypeError: 808 | return data 809 | 810 | def send(self, data, **kwargs): 811 | self.send_data = data 812 | 813 | def connect(self, *args, **kwargs): 814 | pass 815 | 816 | def close(self, *args, **kwargs): 817 | pass 818 | 819 | def settimeout(self, *args, **kwargs): 820 | pass 821 | 822 | def getpeername(self, *args, **kwargs): 823 | return ('192.0.2.23', 9160) 824 | 825 | def getsockname(self, *args, **kwargs): 826 | return ('127.0.0.1', 9160) 827 | 828 | @classmethod 829 | def socket(cls, *args, **kwargs): 830 | return cls.sock 831 | -------------------------------------------------------------------------------- /airplay/vendor/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cnelson/python-airplay/d56e6ae2f7c60aecb5c8e748f3856fbd16a1733c/airplay/vendor/__init__.py -------------------------------------------------------------------------------- /airplay/vendor/httpheader.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | """ Utility functions to work with HTTP headers. 5 | 6 | This module provides some utility functions useful for parsing 7 | and dealing with some of the HTTP 1.1 protocol headers which 8 | are not adequately covered by the standard Python libraries. 9 | 10 | Requires Python 2.2 or later. 11 | 12 | The functionality includes the correct interpretation of the various 13 | Accept-* style headers, content negotiation, byte range requests, 14 | HTTP-style date/times, and more. 15 | 16 | There are a few classes defined by this module: 17 | 18 | * class content_type -- media types such as 'text/plain' 19 | * class language_tag -- language tags such as 'en-US' 20 | * class range_set -- a collection of (byte) range specifiers 21 | * class range_spec -- a single (byte) range specifier 22 | 23 | The primary functions in this module may be categorized as follows: 24 | 25 | * Content negotiation functions... 26 | * acceptable_content_type() 27 | * acceptable_language() 28 | * acceptable_charset() 29 | * acceptable_encoding() 30 | 31 | * Mid-level header parsing functions... 32 | * parse_accept_header() 33 | * parse_accept_language_header() 34 | * parse_range_header() 35 | 36 | * Date and time... 37 | * http_datetime() 38 | * parse_http_datetime() 39 | 40 | * Utility functions... 41 | * quote_string() 42 | * remove_comments() 43 | * canonical_charset() 44 | 45 | * Low level string parsing functions... 46 | * parse_comma_list() 47 | * parse_comment() 48 | * parse_qvalue_accept_list() 49 | * parse_media_type() 50 | * parse_number() 51 | * parse_parameter_list() 52 | * parse_quoted_string() 53 | * parse_range_set() 54 | * parse_range_spec() 55 | * parse_token() 56 | * parse_token_or_quoted_string() 57 | 58 | And there are some specialized exception classes: 59 | 60 | * RangeUnsatisfiableError 61 | * RangeUnmergableError 62 | * ParseError 63 | 64 | See also: 65 | 66 | * RFC 2616, "Hypertext Transfer Protocol -- HTTP/1.1", June 1999. 67 | 68 | Errata at 69 | * RFC 2046, "(MIME) Part Two: Media Types", November 1996. 70 | 71 | * RFC 3066, "Tags for the Identification of Languages", January 2001. 72 | 73 | """ 74 | 75 | __author__ = "Deron Meranda " 76 | __date__ = "2013-02-08" 77 | __version__ = "1.1" 78 | __credits__ = """Copyright (c) 2005-2013 Deron E. Meranda 79 | Licensed under GNU LGPL 2.1 or later. See . 80 | 81 | This library is free software; you can redistribute it and/or 82 | modify it under the terms of the GNU Lesser General Public 83 | License as published by the Free Software Foundation; either 84 | version 2.1 of the License, or (at your option) any later version. 85 | 86 | This library is distributed in the hope that it will be useful, 87 | but WITHOUT ANY WARRANTY; without even the implied warranty of 88 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 89 | Lesser General Public License for more details. 90 | 91 | You should have received a copy of the GNU Lesser General Public 92 | License along with this library; if not, write to the Free Software 93 | Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 94 | """ 95 | 96 | # Character classes from RFC 2616 section 2.2 97 | SEPARATORS = '()<>@,;:\\"/[]?={} \t' 98 | LWS = ' \t\n\r' # linear white space 99 | CRLF = '\r\n' 100 | DIGIT = '0123456789' 101 | HEX = '0123456789ABCDEFabcdef' 102 | 103 | # Try to get a set/frozenset implementation if possible 104 | try: 105 | type(frozenset) 106 | except NameError: 107 | try: 108 | # The demset.py module is available at http://deron.meranda.us/ 109 | from demset import set, frozenset 110 | __emulating_set = True # So we can clean up global namespace later 111 | except ImportError: 112 | pass 113 | 114 | try: 115 | # Turn character classes into set types (for Python 2.4 or greater) 116 | SEPARATORS = frozenset([c for c in SEPARATORS]) 117 | LWS = frozenset([c for c in LWS]) 118 | CRLF = frozenset([c for c in CRLF]) 119 | DIGIT = frozenset([c for c in DIGIT]) 120 | HEX = frozenset([c for c in HEX]) 121 | del c 122 | except NameError: 123 | # Python 2.3 or earlier, leave as simple strings 124 | pass 125 | 126 | 127 | def _is_string( obj ): 128 | """Returns True if the object is a string or unicode type.""" 129 | return isinstance(obj,str) or isinstance(obj,unicode) 130 | 131 | 132 | def http_datetime( dt=None ): 133 | """Formats a datetime as an HTTP 1.1 Date/Time string. 134 | 135 | Takes a standard Python datetime object and returns a string 136 | formatted according to the HTTP 1.1 date/time format. 137 | 138 | If no datetime is provided (or None) then the current 139 | time is used. 140 | 141 | ABOUT TIMEZONES: If the passed in datetime object is naive it is 142 | assumed to be in UTC already. But if it has a tzinfo component, 143 | the returned timestamp string will have been converted to UTC 144 | automatically. So if you use timezone-aware datetimes, you need 145 | not worry about conversion to UTC. 146 | 147 | """ 148 | if not dt: 149 | import datetime 150 | dt = datetime.datetime.utcnow() 151 | else: 152 | try: 153 | dt = dt - dt.utcoffset() 154 | except: 155 | pass # no timezone offset, just assume already in UTC 156 | 157 | s = dt.strftime('%a, %d %b %Y %H:%M:%S GMT') 158 | return s 159 | 160 | 161 | def parse_http_datetime( datestring, utc_tzinfo=None, strict=False ): 162 | """Returns a datetime object from an HTTP 1.1 Date/Time string. 163 | 164 | Note that HTTP dates are always in UTC, so the returned datetime 165 | object will also be in UTC. 166 | 167 | You can optionally pass in a tzinfo object which should represent 168 | the UTC timezone, and the returned datetime will then be 169 | timezone-aware (allowing you to more easly translate it into 170 | different timzeones later). 171 | 172 | If you set 'strict' to True, then only the RFC 1123 format 173 | is recognized. Otherwise the backwards-compatible RFC 1036 174 | and Unix asctime(3) formats are also recognized. 175 | 176 | Please note that the day-of-the-week is not validated. 177 | Also two-digit years, although not HTTP 1.1 compliant, are 178 | treated according to recommended Y2K rules. 179 | 180 | """ 181 | import re, datetime 182 | m = re.match(r'(?P[a-z]+), (?P\d+) (?P[a-z]+) (?P\d+) (?P\d+):(?P\d+):(?P\d+(\.\d+)?) (?P\w+)$', 183 | datestring, re.IGNORECASE) 184 | if not m and not strict: 185 | m = re.match(r'(?P[a-z]+) (?P[a-z]+) (?P\d+) (?P\d+):(?P\d+):(?P\d+) (?P\d+)$', 186 | datestring, re.IGNORECASE) 187 | if not m: 188 | m = re.match(r'(?P[a-z]+), (?P\d+)-(?P[a-z]+)-(?P\d+) (?P\d+):(?P\d+):(?P\d+(\.\d+)?) (?P\w+)$', 189 | datestring, re.IGNORECASE) 190 | if not m: 191 | raise ValueError('HTTP date is not correctly formatted') 192 | 193 | try: 194 | tz = m.group('TZ').upper() 195 | except: 196 | tz = 'GMT' 197 | if tz not in ('GMT','UTC','0000','00:00'): 198 | raise ValueError('HTTP date is not in GMT timezone') 199 | 200 | monname = m.group('MON').upper() 201 | mdict = {'JAN':1, 'FEB':2, 'MAR':3, 'APR':4, 'MAY':5, 'JUN':6, 202 | 'JUL':7, 'AUG':8, 'SEP':9, 'OCT':10, 'NOV':11, 'DEC':12} 203 | month = mdict.get(monname) 204 | if not month: 205 | raise ValueError('HTTP date has an unrecognizable month') 206 | y = int(m.group('Y')) 207 | if y < 100: 208 | century = datetime.datetime.utcnow().year / 100 209 | if y < 50: 210 | y = century * 100 + y 211 | else: 212 | y = (century - 1) * 100 + y 213 | d = int(m.group('D')) 214 | hour = int(m.group('H')) 215 | minute = int(m.group('M')) 216 | try: 217 | second = int(m.group('S')) 218 | except: 219 | second = float(m.group('S')) 220 | dt = datetime.datetime( y, month, d, hour, minute, second, tzinfo=utc_tzinfo ) 221 | return dt 222 | 223 | 224 | class RangeUnsatisfiableError(ValueError): 225 | """Exception class when a byte range lies outside the file size boundaries.""" 226 | def __init__(self, reason=None): 227 | if not reason: 228 | reason = 'Range is unsatisfiable' 229 | ValueError.__init__(self, reason) 230 | 231 | 232 | class RangeUnmergableError(ValueError): 233 | """Exception class when byte ranges are noncontiguous and can not be merged together.""" 234 | def __init__(self, reason=None): 235 | if not reason: 236 | reason = 'Ranges can not be merged together' 237 | ValueError.__init__(self, reason) 238 | 239 | 240 | class ParseError(ValueError): 241 | """Exception class representing a string parsing error.""" 242 | def __init__(self, args, input_string, at_position): 243 | ValueError.__init__(self, args) 244 | self.input_string = input_string 245 | self.at_position = at_position 246 | def __str__(self): 247 | if self.at_position >= len(self.input_string): 248 | return '%s\n\tOccured at end of string' % self.args[0] 249 | else: 250 | return '%s\n\tOccured near %s' % (self.args[0], repr(self.input_string[self.at_position:self.at_position+16])) 251 | 252 | 253 | def is_token(s): 254 | """Determines if the string is a valid token.""" 255 | for c in s: 256 | if ord(c) < 32 or ord(c) > 128 or c in SEPARATORS: 257 | return False 258 | return True 259 | 260 | 261 | def parse_comma_list(s, start=0, element_parser=None, min_count=0, max_count=0): 262 | """Parses a comma-separated list with optional whitespace. 263 | 264 | Takes an optional callback function `element_parser`, which 265 | is assumed to be able to parse an individual element. It 266 | will be passed the string and a `start` argument, and 267 | is expected to return a tuple (parsed_result, chars_consumed). 268 | 269 | If no element_parser is given, then either single tokens or 270 | quoted strings will be parsed. 271 | 272 | If min_count > 0, then at least that many non-empty elements 273 | must be in the list, or an error is raised. 274 | 275 | If max_count > 0, then no more than that many non-empty elements 276 | may be in the list, or an error is raised. 277 | 278 | """ 279 | if min_count > 0 and start == len(s): 280 | raise ParseError('Comma-separated list must contain some elements',s,start) 281 | elif start >= len(s): 282 | raise ParseError('Starting position is beyond the end of the string',s,start) 283 | 284 | if not element_parser: 285 | element_parser = parse_token_or_quoted_string 286 | results = [] 287 | pos = start 288 | while pos < len(s): 289 | e = element_parser( s, pos ) 290 | if not e or e[1] == 0: 291 | break # end of data? 292 | else: 293 | results.append( e[0] ) 294 | pos += e[1] 295 | while pos < len(s) and s[pos] in LWS: 296 | pos += 1 297 | if pos < len(s) and s[pos] != ',': 298 | break 299 | while pos < len(s) and s[pos] == ',': 300 | # skip comma and any "empty" elements 301 | pos += 1 # skip comma 302 | while pos < len(s) and s[pos] in LWS: 303 | pos += 1 304 | if len(results) < min_count: 305 | raise ParseError('Comma-separated list does not have enough elements',s,pos) 306 | elif max_count and len(results) > max_count: 307 | raise ParseError('Comma-separated list has too many elements',s,pos) 308 | return (results, pos-start) 309 | 310 | 311 | def parse_token(s, start=0): 312 | """Parses a token. 313 | 314 | A token is a string defined by RFC 2616 section 2.2 as: 315 | token = 1* 316 | 317 | Returns a tuple (token, chars_consumed), or ('',0) if no token 318 | starts at the given string position. On a syntax error, a 319 | ParseError exception will be raised. 320 | 321 | """ 322 | return parse_token_or_quoted_string(s, start, allow_quoted=False, allow_token=True) 323 | 324 | 325 | def quote_string(s, always_quote=True): 326 | """Produces a quoted string according to HTTP 1.1 rules. 327 | 328 | If always_quote is False and if the string is also a valid token, 329 | then this function may return a string without quotes. 330 | 331 | """ 332 | need_quotes = False 333 | q = '' 334 | for c in s: 335 | if ord(c) < 32 or ord(c) > 127 or c in SEPARATORS: 336 | q += '\\' + c 337 | need_quotes = True 338 | else: 339 | q += c 340 | if need_quotes or always_quote: 341 | return '"' + q + '"' 342 | else: 343 | return q 344 | 345 | 346 | def parse_quoted_string(s, start=0): 347 | """Parses a quoted string. 348 | 349 | Returns a tuple (string, chars_consumed). The quote marks will 350 | have been removed and all \-escapes will have been replaced with 351 | the characters they represent. 352 | 353 | """ 354 | return parse_token_or_quoted_string(s, start, allow_quoted=True, allow_token=False) 355 | 356 | 357 | def parse_token_or_quoted_string(s, start=0, allow_quoted=True, allow_token=True): 358 | """Parses a token or a quoted-string. 359 | 360 | 's' is the string to parse, while start is the position within the 361 | string where parsing should begin. It will returns a tuple 362 | (token, chars_consumed), with all \-escapes and quotation already 363 | processed. 364 | 365 | Syntax is according to BNF rules in RFC 2161 section 2.2, 366 | specifically the 'token' and 'quoted-string' declarations. 367 | Syntax errors in the input string will result in ParseError 368 | being raised. 369 | 370 | If allow_quoted is False, then only tokens will be parsed instead 371 | of either a token or quoted-string. 372 | 373 | If allow_token is False, then only quoted-strings will be parsed 374 | instead of either a token or quoted-string. 375 | """ 376 | if not allow_quoted and not allow_token: 377 | raise ValueError('Parsing can not continue with options provided') 378 | 379 | if start >= len(s): 380 | raise ParseError('Starting position is beyond the end of the string',s,start) 381 | has_quote = (s[start] == '"') 382 | if has_quote and not allow_quoted: 383 | raise ParseError('A quoted string was not expected', s, start) 384 | if not has_quote and not allow_token: 385 | raise ParseError('Expected a quotation mark', s, start) 386 | 387 | s2 = '' 388 | pos = start 389 | if has_quote: 390 | pos += 1 391 | while pos < len(s): 392 | c = s[pos] 393 | if c == '\\' and has_quote: 394 | # Note this is NOT C-style escaping; the character after the \ is 395 | # taken literally. 396 | pos += 1 397 | if pos == len(s): 398 | raise ParseError("End of string while expecting a character after '\\'",s,pos) 399 | s2 += s[pos] 400 | pos += 1 401 | elif c == '"' and has_quote: 402 | break 403 | elif not has_quote and (c in SEPARATORS or ord(c)<32 or ord(c)>127): 404 | break 405 | else: 406 | s2 += c 407 | pos += 1 408 | if has_quote: 409 | # Make sure we have a closing quote mark 410 | if pos >= len(s) or s[pos] != '"': 411 | raise ParseError('Quoted string is missing closing quote mark',s,pos) 412 | else: 413 | pos += 1 414 | return s2, (pos - start) 415 | 416 | 417 | def remove_comments(s, collapse_spaces=True): 418 | """Removes any ()-style comments from a string. 419 | 420 | In HTTP, ()-comments can nest, and this function will correctly 421 | deal with that. 422 | 423 | If 'collapse_spaces' is True, then if there is any whitespace 424 | surrounding the comment, it will be replaced with a single space 425 | character. Whitespace also collapses across multiple comment 426 | sequences, so that "a (b) (c) d" becomes just "a d". 427 | 428 | Otherwise, if 'collapse_spaces' is False then all whitespace which 429 | is outside any comments is left intact as-is. 430 | 431 | """ 432 | if '(' not in s: 433 | return s # simple case 434 | A = [] 435 | dostrip = False 436 | added_comment_space = False 437 | pos = 0 438 | if collapse_spaces: 439 | # eat any leading spaces before a comment 440 | i = s.find('(') 441 | if i >= 0: 442 | while pos < i and s[pos] in LWS: 443 | pos += 1 444 | if pos != i: 445 | pos = 0 446 | else: 447 | dostrip = True 448 | added_comment_space = True # lie 449 | while pos < len(s): 450 | if s[pos] == '(': 451 | cmt, k = parse_comment( s, pos ) 452 | pos += k 453 | if collapse_spaces: 454 | dostrip = True 455 | if not added_comment_space: 456 | if len(A) > 0 and A[-1] and A[-1][-1] in LWS: 457 | # previous part ended with whitespace 458 | A[-1] = A[-1].rstrip() 459 | A.append(' ') # comment becomes one space 460 | added_comment_space = True 461 | else: 462 | i = s.find( '(', pos ) 463 | if i == -1: 464 | if dostrip: 465 | text = s[pos:].lstrip() 466 | if s[pos] in LWS and not added_comment_space: 467 | A.append(' ') 468 | added_comment_space = True 469 | else: 470 | text = s[pos:] 471 | if text: 472 | A.append(text) 473 | dostrip = False 474 | added_comment_space = False 475 | break # end of string 476 | else: 477 | if dostrip: 478 | text = s[pos:i].lstrip() 479 | if s[pos] in LWS and not added_comment_space: 480 | A.append(' ') 481 | added_comment_space = True 482 | else: 483 | text = s[pos:i] 484 | if text: 485 | A.append(text) 486 | dostrip = False 487 | added_comment_space = False 488 | pos = i 489 | if dostrip and len(A) > 0 and A[-1] and A[-1][-1] in LWS: 490 | A[-1] = A[-1].rstrip() 491 | return ''.join(A) 492 | 493 | def parse_comment(s, start=0): 494 | """Parses a ()-style comment from a header value. 495 | 496 | Returns tuple (comment, chars_consumed), where the comment will 497 | have had the outer-most parentheses and white space stripped. Any 498 | nested comments will still have their parentheses and whitespace 499 | left intact. 500 | 501 | All \-escaped quoted pairs will have been replaced with the actual 502 | characters they represent, even within the inner nested comments. 503 | 504 | You should note that only a few HTTP headers, such as User-Agent 505 | or Via, allow ()-style comments within the header value. 506 | 507 | A comment is defined by RFC 2616 section 2.2 as: 508 | 509 | comment = "(" *( ctext | quoted-pair | comment ) ")" 510 | ctext = 511 | """ 512 | if start >= len(s): 513 | raise ParseError('Starting position is beyond the end of the string',s,start) 514 | if s[start] != '(': 515 | raise ParseError('Comment must begin with opening parenthesis',s,start) 516 | 517 | s2 = '' 518 | nestlevel = 1 519 | pos = start + 1 520 | while pos < len(s) and s[pos] in LWS: 521 | pos += 1 522 | 523 | while pos < len(s): 524 | c = s[pos] 525 | if c == '\\': 526 | # Note this is not C-style escaping; the character after the \ is 527 | # taken literally. 528 | pos += 1 529 | if pos == len(s): 530 | raise ParseError("End of string while expecting a character after '\\'",s,pos) 531 | s2 += s[pos] 532 | pos += 1 533 | elif c == '(': 534 | nestlevel += 1 535 | s2 += c 536 | pos += 1 537 | elif c == ')': 538 | nestlevel -= 1 539 | pos += 1 540 | if nestlevel >= 1: 541 | s2 += c 542 | else: 543 | break 544 | else: 545 | s2 += c 546 | pos += 1 547 | if nestlevel > 0: 548 | raise ParseError('End of string reached before comment was closed',s,pos) 549 | # Now rstrip s2 of all LWS chars. 550 | while len(s2) and s2[-1] in LWS: 551 | s2 = s2[:-1] 552 | return s2, (pos - start) 553 | 554 | 555 | class range_spec(object): 556 | """A single contiguous (byte) range. 557 | 558 | A range_spec defines a range (of bytes) by specifying two offsets, 559 | the 'first' and 'last', which are inclusive in the range. Offsets 560 | are zero-based (the first byte is offset 0). The range can not be 561 | empty or negative (has to satisfy first <= last). 562 | 563 | The range can be unbounded on either end, represented here by the 564 | None value, with these semantics: 565 | 566 | * A 'last' of None always indicates the last possible byte 567 | (although that offset may not be known). 568 | 569 | * A 'first' of None indicates this is a suffix range, where 570 | the last value is actually interpreted to be the number 571 | of bytes at the end of the file (regardless of file size). 572 | 573 | Note that it is not valid for both first and last to be None. 574 | 575 | """ 576 | 577 | __slots__ = ['first','last'] 578 | 579 | def __init__(self, first=0, last=None): 580 | self.set( first, last ) 581 | 582 | def set(self, first, last): 583 | """Sets the value of this range given the first and last offsets. 584 | """ 585 | if first is not None and last is not None and first > last: 586 | raise ValueError("Byte range does not satisfy first <= last.") 587 | elif first is None and last is None: 588 | raise ValueError("Byte range can not omit both first and last offsets.") 589 | self.first = first 590 | self.last = last 591 | 592 | def __repr__(self): 593 | return '%s.%s(%s,%s)' % (self.__class__.__module__, self.__class__.__name__, 594 | self.first, self.last) 595 | 596 | def __str__(self): 597 | """Returns a string form of the range as would appear in a Range: header.""" 598 | if self.first is None and self.last is None: 599 | return '' 600 | s = '' 601 | if self.first is not None: 602 | s += '%d' % self.first 603 | s += '-' 604 | if self.last is not None: 605 | s += '%d' % self.last 606 | return s 607 | 608 | def __eq__(self, other): 609 | """Compare ranges for equality. 610 | 611 | Note that if non-specific ranges are involved (such as 34- and -5), 612 | they could compare as not equal even though they may represent 613 | the same set of bytes in some contexts. 614 | """ 615 | return self.first == other.first and self.last == other.last 616 | 617 | def __ne__(self, other): 618 | """Compare ranges for inequality. 619 | 620 | Note that if non-specific ranges are involved (such as 34- and -5), 621 | they could compare as not equal even though they may represent 622 | the same set of bytes in some contexts. 623 | """ 624 | return not self.__eq__(other) 625 | 626 | def __lt__(self, other): 627 | """< operator is not defined""" 628 | raise NotImplementedError('Ranges can not be relationally compared') 629 | def __le__(self, other): 630 | """<= operator is not defined""" 631 | raise NotImplementedError('Ranges can not be ralationally compared') 632 | def __gt__(self, other): 633 | """> operator is not defined""" 634 | raise NotImplementedError('Ranges can not be relationally compared') 635 | def __ge__(self, other): 636 | """>= operator is not defined""" 637 | raise NotImplementedError('Ranges can not be relationally compared') 638 | 639 | def copy(self): 640 | """Makes a copy of this range object.""" 641 | return self.__class__( self.first, self.last ) 642 | 643 | def is_suffix(self): 644 | """Returns True if this is a suffix range. 645 | 646 | A suffix range is one that specifies the last N bytes of a 647 | file regardless of file size. 648 | 649 | """ 650 | return self.first == None 651 | 652 | def is_fixed(self): 653 | """Returns True if this range is absolute and a fixed size. 654 | 655 | This occurs only if neither first or last is None. Converse 656 | is the is_unbounded() method. 657 | 658 | """ 659 | return first is not None and last is not None 660 | 661 | def is_unbounded(self): 662 | """Returns True if the number of bytes in the range is unspecified. 663 | 664 | This can only occur if either the 'first' or the 'last' member 665 | is None. Converse is the is_fixed() method. 666 | 667 | """ 668 | return self.first is None or self.last is None 669 | 670 | def is_whole_file(self): 671 | """Returns True if this range includes all possible bytes. 672 | 673 | This can only occur if the 'last' member is None and the first 674 | member is 0. 675 | 676 | """ 677 | return self.first == 0 and self.last is None 678 | 679 | def __contains__(self, offset): 680 | """Does this byte range contain the given byte offset? 681 | 682 | If the offset < 0, then it is taken as an offset from the end 683 | of the file, where -1 is the last byte. This type of offset 684 | will only work with suffix ranges. 685 | 686 | """ 687 | if offset < 0: 688 | if self.first is not None: 689 | return False 690 | else: 691 | return self.last >= -offset 692 | elif self.first is None: 693 | return False 694 | elif self.last is None: 695 | return True 696 | else: 697 | return self.first <= offset <= self.last 698 | 699 | def fix_to_size(self, size): 700 | """Changes a length-relative range to an absolute range based upon given file size. 701 | 702 | Ranges that are already absolute are left as is. 703 | 704 | Note that zero-length files are handled as special cases, 705 | since the only way possible to specify a zero-length range is 706 | with the suffix range "-0". Thus unless this range is a suffix 707 | range, it can not satisfy a zero-length file. 708 | 709 | If the resulting range (partly) lies outside the file size then an 710 | error is raised. 711 | """ 712 | 713 | if size == 0: 714 | if self.first is None: 715 | self.last = 0 716 | return 717 | else: 718 | raise RangeUnsatisfiableError("Range can satisfy a zero-length file.") 719 | 720 | if self.first is None: 721 | # A suffix range 722 | self.first = size - self.last 723 | if self.first < 0: 724 | self.first = 0 725 | self.last = size - 1 726 | else: 727 | if self.first > size - 1: 728 | raise RangeUnsatisfiableError('Range begins beyond the file size.') 729 | else: 730 | if self.last is None: 731 | # An unbounded range 732 | self.last = size - 1 733 | return 734 | 735 | def merge_with(self, other): 736 | """Tries to merge the given range into this one. 737 | 738 | The size of this range may be enlarged as a result. 739 | 740 | An error is raised if the two ranges do not overlap or are not 741 | contiguous with each other. 742 | """ 743 | if self.is_whole_file() or self == other: 744 | return 745 | elif other.is_whole_file(): 746 | self.first, self.last = 0, None 747 | return 748 | 749 | a1, z1 = self.first, self.last 750 | a2, z2 = other.first, other.last 751 | 752 | if self.is_suffix(): 753 | if z1 == 0: # self is zero-length, so merge becomes a copy 754 | self.first, self.last = a2, z2 755 | return 756 | elif other.is_suffix(): 757 | self.last = max(z1, z2) 758 | else: 759 | raise RangeUnmergableError() 760 | elif other.is_suffix(): 761 | if z2 == 0: # other is zero-length, so nothing to merge 762 | return 763 | else: 764 | raise RangeUnmergableError() 765 | 766 | assert a1 is not None and a2 is not None 767 | 768 | if a2 < a1: 769 | # swap ranges so a1 <= a2 770 | a1, z1, a2, z2 = a2, z2, a1, z1 771 | 772 | assert a1 <= a2 773 | 774 | if z1 is None: 775 | if z2 is not None and z2 + 1 < a1: 776 | raise RangeUnmergableError() 777 | else: 778 | self.first = min(a1, a2) 779 | self.last = None 780 | elif z2 is None: 781 | if z1 + 1 < a2: 782 | raise RangeUnmergableError() 783 | else: 784 | self.first = min(a1, a2) 785 | self.last = None 786 | else: 787 | if a2 > z1 + 1: 788 | raise RangeUnmergableError() 789 | else: 790 | self.first = a1 791 | self.last = max(z1, z2) 792 | return 793 | 794 | 795 | class range_set(object): 796 | """A collection of range_specs, with units (e.g., bytes). 797 | """ 798 | __slots__ = ['units', 'range_specs'] 799 | 800 | def __init__(self): 801 | self.units = 'bytes' 802 | self.range_specs = [] # a list of range_spec objects 803 | 804 | def __str__(self): 805 | return self.units + '=' + ', '.join([str(s) for s in self.range_specs]) 806 | 807 | def __repr__(self): 808 | return '%s.%s(%s)' % (self.__class__.__module__, 809 | self.__class__.__name__, 810 | repr(self.__str__()) ) 811 | 812 | def from_str(self, s, valid_units=('bytes','none')): 813 | """Sets this range set based upon a string, such as the Range: header. 814 | 815 | You can also use the parse_range_set() function for more control. 816 | 817 | If a parsing error occurs, the pre-exising value of this range 818 | set is left unchanged. 819 | 820 | """ 821 | r, k = parse_range_set( s, valid_units=valid_units ) 822 | if k < len(s): 823 | raise ParseError("Extra unparsable characters in range set specifier",s,k) 824 | self.units = r.units 825 | self.range_specs = r.range_specs 826 | 827 | def is_single_range(self): 828 | """Does this range specifier consist of only a single range set?""" 829 | return len(self.range_specs) == 1 830 | 831 | def is_contiguous(self): 832 | """Can the collection of range_specs be coalesced into a single contiguous range?""" 833 | if len(self.range_specs) <= 1: 834 | return True 835 | merged = self.range_specs[0].copy() 836 | for s in self.range_specs[1:]: 837 | try: 838 | merged.merge_with(s) 839 | except: 840 | return False 841 | return True 842 | 843 | def fix_to_size(self, size): 844 | """Changes all length-relative range_specs to absolute range_specs based upon given file size. 845 | If none of the range_specs in this set can be satisfied, then the 846 | entire set is considered unsatifiable and an error is raised. 847 | Otherwise any unsatisfiable range_specs will simply be removed 848 | from this set. 849 | 850 | """ 851 | for i in range(len(self.range_specs)): 852 | try: 853 | self.range_specs[i].fix_to_size( size ) 854 | except RangeUnsatisfiableError: 855 | self.range_specs[i] = None 856 | self.range_specs = [s for s in self.range_specs if s is not None] 857 | if len(self.range_specs) == 0: 858 | raise RangeUnsatisfiableError('No ranges can be satisfied') 859 | 860 | def coalesce(self): 861 | """Collapses all consecutive range_specs which together define a contiguous range. 862 | 863 | Note though that this method will not re-sort the range_specs, so a 864 | potentially contiguous range may not be collapsed if they are 865 | not sorted. For example the ranges: 866 | 10-20, 30-40, 20-30 867 | will not be collapsed to just 10-40. However if the ranges are 868 | sorted first as with: 869 | 10-20, 20-30, 30-40 870 | then they will collapse to 10-40. 871 | """ 872 | if len(self.range_specs) <= 1: 873 | return 874 | for i in range(len(self.range_specs) - 1): 875 | a = self.range_specs[i] 876 | b = self.range_specs[i+1] 877 | if a is not None: 878 | try: 879 | a.merge_with( b ) 880 | self.range_specs[i+1] = None # to be deleted later 881 | except RangeUnmergableError: 882 | pass 883 | self.range_specs = [r for r in self.range_specs if r is not None] 884 | 885 | 886 | def parse_number( s, start=0 ): 887 | """Parses a positive decimal integer number from the string. 888 | 889 | A tuple is returned (number, chars_consumed). If the 890 | string is not a valid decimal number, then (None,0) is returned. 891 | """ 892 | if start >= len(s): 893 | raise ParseError('Starting position is beyond the end of the string',s,start) 894 | if s[start] not in DIGIT: 895 | return (None,0) # not a number 896 | pos = start 897 | n = 0 898 | while pos < len(s): 899 | c = s[pos] 900 | if c in DIGIT: 901 | n *= 10 902 | n += ord(c) - ord('0') 903 | pos += 1 904 | else: 905 | break 906 | return n, pos-start 907 | 908 | 909 | def parse_range_spec( s, start=0 ): 910 | """Parses a (byte) range_spec. 911 | 912 | Returns a tuple (range_spec, chars_consumed). 913 | """ 914 | if start >= len(s): 915 | raise ParseError('Starting position is beyond the end of the string',s,start) 916 | if s[start] not in DIGIT and s[start] != '-': 917 | raise ParseError("Invalid range, expected a digit or '-'",s,start) 918 | first, last = None, None 919 | pos = start 920 | first, k = parse_number( s, pos ) 921 | pos += k 922 | if s[pos] == '-': 923 | pos += 1 924 | if pos < len(s): 925 | last, k = parse_number( s, pos ) 926 | pos += k 927 | else: 928 | raise ParseError("Byte range must include a '-'",s,pos) 929 | if first is None and last is None: 930 | raise ParseError('Byte range can not omit both first and last indices.',s,start) 931 | R = range_spec( first, last ) 932 | return R, pos-start 933 | 934 | 935 | def parse_range_header( header_value, valid_units=('bytes','none') ): 936 | """Parses the value of an HTTP Range: header. 937 | 938 | The value of the header as a string should be passed in; without 939 | the header name itself. 940 | 941 | Returns a range_set object. 942 | """ 943 | ranges, k = parse_range_set( header_value, valid_units=valid_units ) 944 | if k < len(header_value): 945 | raise ParseError('Range header has unexpected or unparsable characters', 946 | header_value, k) 947 | return ranges 948 | 949 | 950 | def parse_range_set( s, start=0, valid_units=('bytes','none') ): 951 | """Parses a (byte) range set specifier. 952 | 953 | Returns a tuple (range_set, chars_consumed). 954 | """ 955 | if start >= len(s): 956 | raise ParseError('Starting position is beyond the end of the string',s,start) 957 | pos = start 958 | units, k = parse_token( s, pos ) 959 | pos += k 960 | if valid_units and units not in valid_units: 961 | raise ParseError('Unsupported units type in range specifier',s,start) 962 | while pos < len(s) and s[pos] in LWS: 963 | pos += 1 964 | if pos < len(s) and s[pos] == '=': 965 | pos += 1 966 | else: 967 | raise ParseError("Invalid range specifier, expected '='",s,pos) 968 | while pos < len(s) and s[pos] in LWS: 969 | pos += 1 970 | range_specs, k = parse_comma_list( s, pos, parse_range_spec, min_count=1 ) 971 | pos += k 972 | # Make sure no trash is at the end of the string 973 | while pos < len(s) and s[pos] in LWS: 974 | pos += 1 975 | if pos < len(s): 976 | raise ParseError('Unparsable characters in range set specifier',s,pos) 977 | 978 | ranges = range_set() 979 | ranges.units = units 980 | ranges.range_specs = range_specs 981 | return ranges, pos-start 982 | 983 | 984 | def _split_at_qfactor( s ): 985 | """Splits a string at the quality factor (;q=) parameter. 986 | 987 | Returns the left and right substrings as a two-member tuple. 988 | 989 | """ 990 | # It may be faster, but incorrect, to use s.split(';q=',1), since 991 | # HTTP allows any amount of linear white space (LWS) to appear 992 | # between the parts, so it could also be "; q = ". 993 | 994 | # We do this parsing 'manually' for speed rather than using a 995 | # regex, which would be r';[ \t\r\n]*q[ \t\r\n]*=[ \t\r\n]*' 996 | 997 | pos = 0 998 | while 0 <= pos < len(s): 999 | pos = s.find(';', pos) 1000 | if pos < 0: 1001 | break # no more parameters 1002 | startpos = pos 1003 | pos = pos + 1 1004 | while pos < len(s) and s[pos] in LWS: 1005 | pos = pos + 1 1006 | if pos < len(s) and s[pos] == 'q': 1007 | pos = pos + 1 1008 | while pos < len(s) and s[pos] in LWS: 1009 | pos = pos + 1 1010 | if pos < len(s) and s[pos] == '=': 1011 | pos = pos + 1 1012 | while pos < len(s) and s[pos] in LWS: 1013 | pos = pos + 1 1014 | return ( s[:startpos], s[pos:] ) 1015 | return (s, '') 1016 | 1017 | 1018 | def parse_qvalue_accept_list( s, start=0, item_parser=parse_token ): 1019 | """Parses any of the Accept-* style headers with quality factors. 1020 | 1021 | This is a low-level function. It returns a list of tuples, each like: 1022 | (item, item_parms, qvalue, accept_parms) 1023 | 1024 | You can pass in a function which parses each of the item strings, or 1025 | accept the default where the items must be simple tokens. Note that 1026 | your parser should not consume any paramters (past the special "q" 1027 | paramter anyway). 1028 | 1029 | The item_parms and accept_parms are each lists of (name,value) tuples. 1030 | 1031 | The qvalue is the quality factor, a number from 0 to 1 inclusive. 1032 | 1033 | """ 1034 | itemlist = [] 1035 | pos = start 1036 | if pos >= len(s): 1037 | raise ParseError('Starting position is beyond the end of the string',s,pos) 1038 | item = None 1039 | while pos < len(s): 1040 | item, k = item_parser(s, pos) 1041 | pos += k 1042 | while pos < len(s) and s[pos] in LWS: 1043 | pos += 1 1044 | if pos >= len(s) or s[pos] in ',;': 1045 | itemparms, qvalue, acptparms = [], None, [] 1046 | if pos < len(s) and s[pos] == ';': 1047 | pos += 1 1048 | while pos < len(s) and s[pos] in LWS: 1049 | pos += 1 1050 | parmlist, k = parse_parameter_list(s, pos) 1051 | for p, v in parmlist: 1052 | if p == 'q' and qvalue is None: 1053 | try: 1054 | qvalue = float(v) 1055 | except ValueError: 1056 | raise ParseError('qvalue must be a floating point number',s,pos) 1057 | if qvalue < 0 or qvalue > 1: 1058 | raise ParseError('qvalue must be between 0 and 1, inclusive',s,pos) 1059 | elif qvalue is None: 1060 | itemparms.append( (p,v) ) 1061 | else: 1062 | acptparms.append( (p,v) ) 1063 | pos += k 1064 | if item: 1065 | # Add the item to the list 1066 | if qvalue is None: 1067 | qvalue = 1 1068 | itemlist.append( (item, itemparms, qvalue, acptparms) ) 1069 | item = None 1070 | # skip commas 1071 | while pos < len(s) and s[pos] == ',': 1072 | pos += 1 1073 | while pos < len(s) and s[pos] in LWS: 1074 | pos += 1 1075 | else: 1076 | break 1077 | return itemlist, pos - start 1078 | 1079 | 1080 | def parse_accept_header( header_value ): 1081 | """Parses the Accept: header. 1082 | 1083 | The value of the header as a string should be passed in; without 1084 | the header name itself. 1085 | 1086 | This will parse the value of any of the HTTP headers "Accept", 1087 | "Accept-Charset", "Accept-Encoding", or "Accept-Language". These 1088 | headers are similarly formatted, in that they are a list of items 1089 | with associated quality factors. The quality factor, or qvalue, 1090 | is a number in the range [0.0..1.0] which indicates the relative 1091 | preference of each item. 1092 | 1093 | This function returns a list of those items, sorted by preference 1094 | (from most-prefered to least-prefered). Each item in the returned 1095 | list is actually a tuple consisting of: 1096 | 1097 | ( item_name, item_parms, qvalue, accept_parms ) 1098 | 1099 | As an example, the following string, 1100 | text/plain; charset="utf-8"; q=.5; columns=80 1101 | would be parsed into this resulting tuple, 1102 | ( 'text/plain', [('charset','utf-8')], 0.5, [('columns','80')] ) 1103 | 1104 | The value of the returned item_name depends upon which header is 1105 | being parsed, but for example it may be a MIME content or media 1106 | type (without parameters), a language tag, or so on. Any optional 1107 | parameters (delimited by semicolons) occuring before the "q=" 1108 | attribute will be in the item_parms list as (attribute,value) 1109 | tuples in the same order as they appear in the header. Any quoted 1110 | values will have been unquoted and unescaped. 1111 | 1112 | The qvalue is a floating point number in the inclusive range 0.0 1113 | to 1.0, and roughly indicates the preference for this item. 1114 | Values outside this range will be capped to the closest extreme. 1115 | 1116 | (!) Note that a qvalue of 0 indicates that the item is 1117 | explicitly NOT acceptable to the user agent, and should be 1118 | handled differently by the caller. 1119 | 1120 | The accept_parms, like the item_parms, is a list of any attributes 1121 | occuring after the "q=" attribute, and will be in the list as 1122 | (attribute,value) tuples in the same order as they occur. 1123 | Usually accept_parms will be an empty list, as the HTTP spec 1124 | allows these extra parameters in the syntax but does not 1125 | currently define any possible values. 1126 | 1127 | All empty items will be removed from the list. However, duplicate 1128 | or conflicting values are not detected or handled in any way by 1129 | this function. 1130 | """ 1131 | def parse_mt_only(s, start): 1132 | mt, k = parse_media_type(s, start, with_parameters=False) 1133 | ct = content_type() 1134 | ct.major = mt[0] 1135 | ct.minor = mt[1] 1136 | return ct, k 1137 | 1138 | alist, k = parse_qvalue_accept_list( header_value, item_parser=parse_mt_only ) 1139 | if k < len(header_value): 1140 | raise ParseError('Accept header is invalid',header_value,k) 1141 | 1142 | ctlist = [] 1143 | for ct, ctparms, q, acptparms in alist: 1144 | if ctparms: 1145 | ct.set_parameters( dict(ctparms) ) 1146 | ctlist.append( (ct, q, acptparms) ) 1147 | return ctlist 1148 | 1149 | 1150 | def parse_media_type(media_type, start=0, with_parameters=True): 1151 | """Parses a media type (MIME type) designator into it's parts. 1152 | 1153 | Given a media type string, returns a nested tuple of it's parts. 1154 | 1155 | ((major,minor,parmlist), chars_consumed) 1156 | 1157 | where parmlist is a list of tuples of (parm_name, parm_value). 1158 | Quoted-values are appropriately unquoted and unescaped. 1159 | 1160 | If 'with_parameters' is False, then parsing will stop immediately 1161 | after the minor media type; and will not proceed to parse any 1162 | of the semicolon-separated paramters. 1163 | 1164 | Examples: 1165 | image/png -> (('image','png',[]), 9) 1166 | text/plain; charset="utf-16be" 1167 | -> (('text','plain',[('charset,'utf-16be')]), 30) 1168 | 1169 | """ 1170 | 1171 | s = media_type 1172 | pos = start 1173 | ctmaj, k = parse_token(s, pos) 1174 | if k == 0: 1175 | raise ParseError('Media type must be of the form "major/minor".', s, pos) 1176 | pos += k 1177 | if pos >= len(s) or s[pos] != '/': 1178 | raise ParseError('Media type must be of the form "major/minor".', s, pos) 1179 | pos += 1 1180 | ctmin, k = parse_token(s, pos) 1181 | if k == 0: 1182 | raise ParseError('Media type must be of the form "major/minor".', s, pos) 1183 | pos += k 1184 | if with_parameters: 1185 | parmlist, k = parse_parameter_list(s, pos) 1186 | pos += k 1187 | else: 1188 | parmlist = [] 1189 | return ((ctmaj, ctmin, parmlist), pos - start) 1190 | 1191 | 1192 | def parse_parameter_list(s, start=0): 1193 | """Parses a semicolon-separated 'parameter=value' list. 1194 | 1195 | Returns a tuple (parmlist, chars_consumed), where parmlist 1196 | is a list of tuples (parm_name, parm_value). 1197 | 1198 | The parameter values will be unquoted and unescaped as needed. 1199 | 1200 | Empty parameters (as in ";;") are skipped, as is insignificant 1201 | white space. The list returned is kept in the same order as the 1202 | parameters appear in the string. 1203 | 1204 | """ 1205 | pos = start 1206 | parmlist = [] 1207 | while pos < len(s): 1208 | while pos < len(s) and s[pos] in LWS: 1209 | pos += 1 # skip whitespace 1210 | if pos < len(s) and s[pos] == ';': 1211 | pos += 1 1212 | while pos < len(s) and s[pos] in LWS: 1213 | pos += 1 # skip whitespace 1214 | if pos >= len(s): 1215 | break 1216 | parmname, k = parse_token(s, pos) 1217 | if parmname: 1218 | pos += k 1219 | while pos < len(s) and s[pos] in LWS: 1220 | pos += 1 # skip whitespace 1221 | if not (pos < len(s) and s[pos] == '='): 1222 | raise ParseError('Expected an "=" after parameter name', s, pos) 1223 | pos += 1 1224 | while pos < len(s) and s[pos] in LWS: 1225 | pos += 1 # skip whitespace 1226 | parmval, k = parse_token_or_quoted_string( s, pos ) 1227 | pos += k 1228 | parmlist.append( (parmname, parmval) ) 1229 | else: 1230 | break 1231 | return parmlist, pos - start 1232 | 1233 | 1234 | class content_type(object): 1235 | """This class represents a media type (aka a MIME content type), including parameters. 1236 | 1237 | You initialize these by passing in a content-type declaration 1238 | string, such as "text/plain; charset=ascii", to the constructor or 1239 | to the set() method. If you provide no string value, the object 1240 | returned will represent the wildcard */* content type. 1241 | 1242 | Normally you will get the value back by using str(), or optionally 1243 | you can access the components via the 'major', 'minor', 'media_type', 1244 | or 'parmdict' members. 1245 | 1246 | """ 1247 | def __init__(self, content_type_string=None, with_parameters=True): 1248 | """Create a new content_type object. 1249 | 1250 | See the set() method for a description of the arguments. 1251 | """ 1252 | if content_type_string: 1253 | self.set( content_type_string, with_parameters=with_parameters ) 1254 | else: 1255 | self.set( '*/*' ) 1256 | 1257 | def set_parameters(self, parameter_list_or_dict): 1258 | """Sets the optional paramters based upon the parameter list. 1259 | 1260 | The paramter list should be a semicolon-separated name=value string. 1261 | Any paramters which already exist on this object will be deleted, 1262 | unless they appear in the given paramter_list. 1263 | 1264 | """ 1265 | if hasattr(parameter_list_or_dict, 'has_key'): 1266 | # already a dictionary 1267 | pl = parameter_list_or_dict 1268 | else: 1269 | pl, k = parse_parameter_list(parameter_list) 1270 | if k < len(parameter_list): 1271 | raise ParseError('Invalid parameter list',paramter_list,k) 1272 | self.parmdict = dict(pl) 1273 | 1274 | def set(self, content_type_string, with_parameters=True): 1275 | """Parses the content type string and sets this object to it's value. 1276 | 1277 | For a more complete description of the arguments, see the 1278 | documentation for the parse_media_type() function in this module. 1279 | """ 1280 | mt, k = parse_media_type( content_type_string, with_parameters=with_parameters ) 1281 | if k < len(content_type_string): 1282 | raise ParseError('Not a valid content type',content_type_string, k) 1283 | major, minor, pdict = mt 1284 | self._set_major( major ) 1285 | self._set_minor( minor ) 1286 | self.parmdict = dict(pdict) 1287 | 1288 | def _get_major(self): 1289 | return self._major 1290 | def _set_major(self, s): 1291 | s = s.lower() # case-insentive 1292 | if not is_token(s): 1293 | raise ValueError('Major media type contains an invalid character') 1294 | self._major = s 1295 | 1296 | def _get_minor(self): 1297 | return self._minor 1298 | def _set_minor(self, s): 1299 | s = s.lower() # case-insentive 1300 | if not is_token(s): 1301 | raise ValueError('Minor media type contains an invalid character') 1302 | self._minor = s 1303 | 1304 | major = property(_get_major,_set_major,doc="Major media classification") 1305 | minor = property(_get_minor,_set_minor,doc="Minor media sub-classification") 1306 | 1307 | def __nonzero__(self): 1308 | return True 1309 | 1310 | def __str__(self): 1311 | """String value.""" 1312 | s = '%s/%s' % (self.major, self.minor) 1313 | if self.parmdict: 1314 | extra = '; '.join([ '%s=%s' % (a[0],quote_string(a[1],False)) \ 1315 | for a in self.parmdict.items()]) 1316 | s += '; ' + extra 1317 | return s 1318 | 1319 | def __unicode__(self): 1320 | """Unicode string value.""" 1321 | return unicode(self.__str__()) 1322 | 1323 | def __repr__(self): 1324 | """Python representation of this object.""" 1325 | s = '%s(%s)' % (self.__class__.__name__, repr(self.__str__())) 1326 | return s 1327 | 1328 | 1329 | def __hash__(self): 1330 | """Hash this object; the hash is dependent only upon the value.""" 1331 | return hash(str(self)) 1332 | 1333 | def __getstate__(self): 1334 | """Pickler""" 1335 | return str(self) 1336 | 1337 | def __setstate__(self, state): 1338 | """Unpickler""" 1339 | self.set(state) 1340 | 1341 | def __len__(self): 1342 | """Logical length of this media type. 1343 | For example: 1344 | len('*/*') -> 0 1345 | len('image/*') -> 1 1346 | len('image/png') -> 2 1347 | len('text/plain; charset=utf-8') -> 3 1348 | len('text/plain; charset=utf-8; filename=xyz.txt') -> 4 1349 | 1350 | """ 1351 | if self.major == '*': 1352 | return 0 1353 | elif self.minor == '*': 1354 | return 1 1355 | else: 1356 | return 2 + len(self.parmdict) 1357 | 1358 | def __eq__(self, other): 1359 | """Equality test. 1360 | 1361 | Note that this is an exact match, including any parameters if any. 1362 | """ 1363 | return self.major == other.major and \ 1364 | self.minor == other.minor and \ 1365 | self.parmdict == other.parmdict 1366 | 1367 | def __ne__(self, other): 1368 | """Inequality test.""" 1369 | return not self.__eq__(other) 1370 | 1371 | def _get_media_type(self): 1372 | """Returns the media 'type/subtype' string, without parameters.""" 1373 | return '%s/%s' % (self.major, self.minor) 1374 | 1375 | media_type = property(_get_media_type, doc="Returns the just the media type 'type/subtype' without any paramters (read-only).") 1376 | 1377 | def is_wildcard(self): 1378 | """Returns True if this is a 'something/*' media type. 1379 | """ 1380 | return self.minor == '*' 1381 | 1382 | def is_universal_wildcard(self): 1383 | """Returns True if this is the unspecified '*/*' media type. 1384 | """ 1385 | return self.major == '*' and self.minor == '*' 1386 | 1387 | def is_composite(self): 1388 | """Is this media type composed of multiple parts. 1389 | """ 1390 | return self.major == 'multipart' or self.major == 'message' 1391 | 1392 | def is_xml(self): 1393 | """Returns True if this media type is XML-based. 1394 | 1395 | Note this does not consider text/html to be XML, but 1396 | application/xhtml+xml is. 1397 | """ 1398 | return self.minor == 'xml' or self.minor.endswith('+xml') 1399 | 1400 | # Some common media types 1401 | content_formdata = content_type('multipart/form-data') 1402 | content_urlencoded = content_type('application/x-www-form-urlencoded') 1403 | content_byteranges = content_type('multipart/byteranges') # RFC 2616 sect 14.16 1404 | content_opaque = content_type('application/octet-stream') 1405 | content_html = content_type('text/html') 1406 | content_xhtml = content_type('application/xhtml+xml') 1407 | 1408 | 1409 | def acceptable_content_type( accept_header, content_types, ignore_wildcard=True ): 1410 | """Determines if the given content type is acceptable to the user agent. 1411 | 1412 | The accept_header should be the value present in the HTTP 1413 | "Accept:" header. In mod_python this is typically obtained from 1414 | the req.http_headers_in table; in WSGI it is environ["Accept"]; 1415 | other web frameworks may provide other methods of obtaining it. 1416 | 1417 | Optionally the accept_header parameter can be pre-parsed, as 1418 | returned from the parse_accept_header() function in this module. 1419 | 1420 | The content_types argument should either be a single MIME media 1421 | type string, or a sequence of them. It represents the set of 1422 | content types that the caller (server) is willing to send. 1423 | Generally, the server content_types should not contain any 1424 | wildcarded values. 1425 | 1426 | This function determines which content type which is the most 1427 | preferred and is acceptable to both the user agent and the server. 1428 | If one is negotiated it will return a four-valued tuple like: 1429 | 1430 | (server_content_type, ua_content_range, qvalue, accept_parms) 1431 | 1432 | The first tuple value is one of the server's content_types, while 1433 | the remaining tuple values descript which of the client's 1434 | acceptable content_types was matched. In most cases accept_parms 1435 | will be an empty list (see description of parse_accept_header() 1436 | for more details). 1437 | 1438 | If no content type could be negotiated, then this function will 1439 | return None (and the caller should typically cause an HTTP 406 Not 1440 | Acceptable as a response). 1441 | 1442 | Note that the wildcarded content type "*/*" sent by the client 1443 | will be ignored, since it is often incorrectly sent by web 1444 | browsers that don't really mean it. To override this, call with 1445 | ignore_wildcard=False. Partial wildcards such as "image/*" will 1446 | always be processed, but be at a lower priority than a complete 1447 | matching type. 1448 | 1449 | See also: RFC 2616 section 14.1, and 1450 | 1451 | 1452 | """ 1453 | if _is_string(accept_header): 1454 | accept_list = parse_accept_header(accept_header) 1455 | else: 1456 | accept_list = accept_header 1457 | 1458 | if _is_string(content_types): 1459 | content_types = [content_types] 1460 | 1461 | server_ctlist = [content_type(ct) for ct in content_types] 1462 | del ct 1463 | 1464 | #print 'AC', repr(accept_list) 1465 | #print 'SV', repr(server_ctlist) 1466 | 1467 | best = None # (content_type, qvalue, accept_parms, matchlen) 1468 | 1469 | for server_ct in server_ctlist: 1470 | best_for_this = None 1471 | for client_ct, qvalue, aargs in accept_list: 1472 | if ignore_wildcard and client_ct.is_universal_wildcard(): 1473 | continue # */* being ignored 1474 | 1475 | matchlen = 0 # how specifically this one matches (0 is a non-match) 1476 | if client_ct.is_universal_wildcard(): 1477 | matchlen = 1 # */* is a 1 1478 | elif client_ct.major == server_ct.major: 1479 | if client_ct.minor == '*': # something/* is a 2 1480 | matchlen = 2 1481 | elif client_ct.minor == server_ct.minor: # something/something is a 3 1482 | matchlen = 3 1483 | # must make sure all the parms match too 1484 | for pname, pval in client_ct.parmdict.items(): 1485 | sval = server_ct.parmdict.get(pname) 1486 | if pname == 'charset': 1487 | # special case for charset to match aliases 1488 | pval = canonical_charset(pval) 1489 | sval = canonical_charset(sval) 1490 | if sval == pval: 1491 | matchlen = matchlen + 1 1492 | else: 1493 | matchlen = 0 1494 | break 1495 | else: 1496 | matchlen = 0 1497 | 1498 | #print 'S',server_ct,' C',client_ct,' M',matchlen,'Q',qvalue 1499 | if matchlen > 0: 1500 | if not best_for_this \ 1501 | or matchlen > best_for_this[-1] \ 1502 | or (matchlen == best_for_this[-1] and qvalue > best_for_this[2]): 1503 | # This match is better 1504 | best_for_this = (server_ct, client_ct, qvalue, aargs, matchlen) 1505 | #print 'BEST2 NOW', repr(best_for_this) 1506 | if not best or \ 1507 | (best_for_this and best_for_this[2] > best[2]): 1508 | best = best_for_this 1509 | #print 'BEST NOW', repr(best) 1510 | if not best or best[1] <= 0: 1511 | return None 1512 | return best[:-1] 1513 | 1514 | 1515 | # Aliases of common charsets, see . 1516 | character_set_aliases = { 1517 | 'ASCII': 'US-ASCII', 1518 | 'ISO646-US': 'US-ASCII', 1519 | 'IBM367': 'US-ASCII', 1520 | 'CP367': 'US-ASCII', 1521 | 'CSASCII': 'US-ASCII', 1522 | 'ANSI_X3.4-1968': 'US-ASCII', 1523 | 'ISO_646.IRV:1991': 'US-ASCII', 1524 | 1525 | 'UTF7': 'UTF-7', 1526 | 1527 | 'UTF8': 'UTF-8', 1528 | 1529 | 'UTF16': 'UTF-16', 1530 | 'UTF16LE': 'UTF-16LE', 1531 | 'UTF16BE': 'UTF-16BE', 1532 | 1533 | 'UTF32': 'UTF-32', 1534 | 'UTF32LE': 'UTF-32LE', 1535 | 'UTF32BE': 'UTF-32BE', 1536 | 1537 | 'UCS2': 'ISO-10646-UCS-2', 1538 | 'UCS_2': 'ISO-10646-UCS-2', 1539 | 'UCS-2': 'ISO-10646-UCS-2', 1540 | 'CSUNICODE': 'ISO-10646-UCS-2', 1541 | 1542 | 'UCS4': 'ISO-10646-UCS-4', 1543 | 'UCS_4': 'ISO-10646-UCS-4', 1544 | 'UCS-4': 'ISO-10646-UCS-4', 1545 | 'CSUCS4': 'ISO-10646-UCS-4', 1546 | 1547 | 'ISO_8859-1': 'ISO-8859-1', 1548 | 'LATIN1': 'ISO-8859-1', 1549 | 'CP819': 'ISO-8859-1', 1550 | 'IBM819': 'ISO-8859-1', 1551 | 1552 | 'ISO_8859-2': 'ISO-8859-2', 1553 | 'LATIN2': 'ISO-8859-2', 1554 | 1555 | 'ISO_8859-3': 'ISO-8859-3', 1556 | 'LATIN3': 'ISO-8859-3', 1557 | 1558 | 'ISO_8859-4': 'ISO-8859-4', 1559 | 'LATIN4': 'ISO-8859-4', 1560 | 1561 | 'ISO_8859-5': 'ISO-8859-5', 1562 | 'CYRILLIC': 'ISO-8859-5', 1563 | 1564 | 'ISO_8859-6': 'ISO-8859-6', 1565 | 'ARABIC': 'ISO-8859-6', 1566 | 'ECMA-114': 'ISO-8859-6', 1567 | 1568 | 'ISO_8859-6-E': 'ISO-8859-6-E', 1569 | 'ISO_8859-6-I': 'ISO-8859-6-I', 1570 | 1571 | 'ISO_8859-7': 'ISO-8859-7', 1572 | 'GREEK': 'ISO-8859-7', 1573 | 'GREEK8': 'ISO-8859-7', 1574 | 'ECMA-118': 'ISO-8859-7', 1575 | 1576 | 'ISO_8859-8': 'ISO-8859-8', 1577 | 'HEBREW': 'ISO-8859-8', 1578 | 1579 | 'ISO_8859-8-E': 'ISO-8859-8-E', 1580 | 'ISO_8859-8-I': 'ISO-8859-8-I', 1581 | 1582 | 'ISO_8859-9': 'ISO-8859-9', 1583 | 'LATIN5': 'ISO-8859-9', 1584 | 1585 | 'ISO_8859-10': 'ISO-8859-10', 1586 | 'LATIN6': 'ISO-8859-10', 1587 | 1588 | 'ISO_8859-13': 'ISO-8859-13', 1589 | 1590 | 'ISO_8859-14': 'ISO-8859-14', 1591 | 'LATIN8': 'ISO-8859-14', 1592 | 1593 | 'ISO_8859-15': 'ISO-8859-15', 1594 | 'LATIN9': 'ISO-8859-15', 1595 | 1596 | 'ISO_8859-16': 'ISO-8859-16', 1597 | 'LATIN10': 'ISO-8859-16', 1598 | } 1599 | 1600 | def canonical_charset( charset ): 1601 | """Returns the canonical or preferred name of a charset. 1602 | 1603 | Additional character sets can be recognized by this function by 1604 | altering the character_set_aliases dictionary in this module. 1605 | Charsets which are not recognized are simply converted to 1606 | upper-case (as charset names are always case-insensitive). 1607 | 1608 | See . 1609 | 1610 | """ 1611 | # It would be nice to use Python's codecs modules for this, but 1612 | # there is no fixed public interface to it's alias mappings. 1613 | if not charset: 1614 | return charset 1615 | uc = charset.upper() 1616 | uccon = character_set_aliases.get( uc, uc ) 1617 | return uccon 1618 | 1619 | 1620 | def acceptable_charset( accept_charset_header, charsets, ignore_wildcard=True, default='ISO-8859-1' ): 1621 | """ 1622 | Determines if the given charset is acceptable to the user agent. 1623 | 1624 | The accept_charset_header should be the value present in the HTTP 1625 | "Accept-Charset:" header. In mod_python this is typically 1626 | obtained from the req.http_headers table; in WSGI it is 1627 | environ["Accept-Charset"]; other web frameworks may provide other 1628 | methods of obtaining it. 1629 | 1630 | Optionally the accept_charset_header parameter can instead be the 1631 | list returned from the parse_accept_header() function in this 1632 | module. 1633 | 1634 | The charsets argument should either be a charset identifier string, 1635 | or a sequence of them. 1636 | 1637 | This function returns the charset identifier string which is the 1638 | most prefered and is acceptable to both the user agent and the 1639 | caller. It will return the default value if no charset is negotiable. 1640 | 1641 | Note that the wildcarded charset "*" will be ignored. To override 1642 | this, call with ignore_wildcard=False. 1643 | 1644 | See also: RFC 2616 section 14.2, and 1645 | 1646 | 1647 | """ 1648 | if default: 1649 | default = _canonical_charset(default) 1650 | 1651 | if _is_string(accept_charset_header): 1652 | accept_list = parse_accept_header(accept_charset_header) 1653 | else: 1654 | accept_list = accept_charset_header 1655 | 1656 | if _is_string(charsets): 1657 | charsets = [_canonical_charset(charsets)] 1658 | else: 1659 | charsets = [_canonical_charset(c) for c in charsets] 1660 | 1661 | # Note per RFC that 'ISO-8859-1' is special, and is implictly in the 1662 | # accept list with q=1; unless it is already in the list, or '*' is in the list. 1663 | 1664 | best = None 1665 | for c, qvalue, junk in accept_list: 1666 | if c == '*': 1667 | default = None 1668 | if ignore_wildcard: 1669 | continue 1670 | if not best or qvalue > best[1]: 1671 | best = (c, qvalue) 1672 | else: 1673 | c = _canonical_charset(c) 1674 | for test_c in charsets: 1675 | if c == default: 1676 | default = None 1677 | if c == test_c and (not best or best[0]=='*' or qvalue > best[1]): 1678 | best = (c, qvalue) 1679 | if default and default in [test_c.upper() for test_c in charsets]: 1680 | best = (default, 1) 1681 | if best[0] == '*': 1682 | best = (charsets[0], best[1]) 1683 | return best 1684 | 1685 | 1686 | 1687 | class language_tag(object): 1688 | """This class represents an RFC 3066 language tag. 1689 | 1690 | Initialize objects of this class with a single string representing 1691 | the language tag, such as "en-US". 1692 | 1693 | Case is insensitive. Wildcarded subtags are ignored or stripped as 1694 | they have no significance, so that "en-*" is the same as "en". 1695 | However the universal wildcard "*" language tag is kept as-is. 1696 | 1697 | Note that although relational operators such as < are defined, 1698 | they only form a partial order based upon specialization. 1699 | 1700 | Thus for example, 1701 | "en" <= "en-US" 1702 | but, 1703 | not "en" <= "de", and 1704 | not "de" <= "en". 1705 | 1706 | """ 1707 | 1708 | def __init__(self, tagname): 1709 | """Initialize objects of this class with a single string representing 1710 | the language tag, such as "en-US". Case is insensitive. 1711 | 1712 | """ 1713 | 1714 | self.parts = tagname.lower().split('-') 1715 | while len(self.parts) > 1 and self.parts[-1] == '*': 1716 | del self.parts[-1] 1717 | 1718 | def __len__(self): 1719 | """Number of subtags in this tag.""" 1720 | if len(self.parts) == 1 and self.parts[0] == '*': 1721 | return 0 1722 | return len(self.parts) 1723 | 1724 | def __str__(self): 1725 | """The standard string form of this language tag.""" 1726 | a = [] 1727 | if len(self.parts) >= 1: 1728 | a.append(self.parts[0]) 1729 | if len(self.parts) >= 2: 1730 | if len(self.parts[1]) == 2: 1731 | a.append( self.parts[1].upper() ) 1732 | else: 1733 | a.append( self.parts[1] ) 1734 | a.extend( self.parts[2:] ) 1735 | return '-'.join(a) 1736 | 1737 | def __unicode__(self): 1738 | """The unicode string form of this language tag.""" 1739 | return unicode(self.__str__()) 1740 | 1741 | def __repr__(self): 1742 | """The python representation of this language tag.""" 1743 | s = '%s("%s")' % (self.__class__.__name__, self.__str__()) 1744 | return s 1745 | 1746 | def superior(self): 1747 | """Returns another instance of language_tag which is the superior. 1748 | 1749 | Thus en-US gives en, and en gives *. 1750 | 1751 | """ 1752 | if len(self) <= 1: 1753 | return self.__class__('*') 1754 | return self.__class__( '-'.join(self.parts[:-1]) ) 1755 | 1756 | def all_superiors(self, include_wildcard=False): 1757 | """Returns a list of this language and all it's superiors. 1758 | 1759 | If include_wildcard is False, then "*" will not be among the 1760 | output list, unless this language is itself "*". 1761 | 1762 | """ 1763 | langlist = [ self ] 1764 | l = self 1765 | while not l.is_universal_wildcard(): 1766 | l = l.superior() 1767 | if l.is_universal_wildcard() and not include_wildcard: 1768 | continue 1769 | langlist.append(l) 1770 | return langlist 1771 | 1772 | def is_universal_wildcard(self): 1773 | """Returns True if this language tag represents all possible 1774 | languages, by using the reserved tag of "*". 1775 | 1776 | """ 1777 | return len(self.parts) == 1 and self.parts[0] == '*' 1778 | 1779 | def dialect_of(self, other, ignore_wildcard=True): 1780 | """Is this language a dialect (or subset/specialization) of another. 1781 | 1782 | This method returns True if this language is the same as or a 1783 | specialization (dialect) of the other language_tag. 1784 | 1785 | If ignore_wildcard is False, then all languages will be 1786 | considered to be a dialect of the special language tag of "*". 1787 | 1788 | """ 1789 | if not ignore_wildcard and self.is_universal_wildcard(): 1790 | return True 1791 | for i in range( min(len(self), len(other)) ): 1792 | if self.parts[i] != other.parts[i]: 1793 | return False 1794 | if len(self) >= len(other): 1795 | return True 1796 | return False 1797 | 1798 | def __eq__(self, other): 1799 | """== operator. Are the two languages the same?""" 1800 | 1801 | return self.parts == other.parts 1802 | 1803 | def __neq__(self, other): 1804 | """!= operator. Are the two languages different?""" 1805 | 1806 | return not self.__eq__(other) 1807 | 1808 | def __lt__(self, other): 1809 | """< operator. Returns True if the other language is a more 1810 | specialized dialect of this one.""" 1811 | 1812 | return other.dialect_of(self) and self != other 1813 | 1814 | def __le__(self, other): 1815 | """<= operator. Returns True if the other language is the same 1816 | as or a more specialized dialect of this one.""" 1817 | return other.dialect_of(self) 1818 | 1819 | def __gt__(self, other): 1820 | """> operator. Returns True if this language is a more 1821 | specialized dialect of the other one.""" 1822 | 1823 | return self.dialect_of(other) and self != other 1824 | 1825 | def __ge__(self, other): 1826 | """>= operator. Returns True if this language is the same as 1827 | or a more specialized dialect of the other one.""" 1828 | 1829 | return self.dialect_of(other) 1830 | 1831 | 1832 | def parse_accept_language_header( header_value ): 1833 | """Parses the Accept-Language header. 1834 | 1835 | Returns a list of tuples, each like: 1836 | 1837 | (language_tag, qvalue, accept_parameters) 1838 | 1839 | """ 1840 | alist, k = parse_qvalue_accept_list( header_value) 1841 | if k < len(header_value): 1842 | raise ParseError('Accept-Language header is invalid',header_value,k) 1843 | 1844 | langlist = [] 1845 | for token, langparms, q, acptparms in alist: 1846 | if langparms: 1847 | raise ParseError('Language tag may not have any parameters',header_value,0) 1848 | lang = language_tag( token ) 1849 | langlist.append( (lang, q, acptparms) ) 1850 | 1851 | return langlist 1852 | 1853 | 1854 | def acceptable_language( accept_header, server_languages, ignore_wildcard=True, assume_superiors=True ): 1855 | """Determines if the given language is acceptable to the user agent. 1856 | 1857 | The accept_header should be the value present in the HTTP 1858 | "Accept-Language:" header. In mod_python this is typically 1859 | obtained from the req.http_headers_in table; in WSGI it is 1860 | environ["Accept-Language"]; other web frameworks may provide other 1861 | methods of obtaining it. 1862 | 1863 | Optionally the accept_header parameter can be pre-parsed, as 1864 | returned by the parse_accept_language_header() function defined in 1865 | this module. 1866 | 1867 | The server_languages argument should either be a single language 1868 | string, a language_tag object, or a sequence of them. It 1869 | represents the set of languages that the server is willing to 1870 | send to the user agent. 1871 | 1872 | Note that the wildcarded language tag "*" will be ignored. To 1873 | override this, call with ignore_wildcard=False, and even then 1874 | it will be the lowest-priority choice regardless of it's 1875 | quality factor (as per HTTP spec). 1876 | 1877 | If the assume_superiors is True then it the languages that the 1878 | browser accepts will automatically include all superior languages. 1879 | Any superior languages which must be added are done so with one 1880 | half the qvalue of the language which is present. For example, if 1881 | the accept string is "en-US", then it will be treated as if it 1882 | were "en-US, en;q=0.5". Note that although the HTTP 1.1 spec says 1883 | that browsers are supposed to encourage users to configure all 1884 | acceptable languages, sometimes they don't, thus the ability 1885 | for this function to assume this. But setting assume_superiors 1886 | to False will insure strict adherence to the HTTP 1.1 spec; which 1887 | means that if the browser accepts "en-US", then it will not 1888 | be acceptable to send just "en" to it. 1889 | 1890 | This function returns the language which is the most prefered and 1891 | is acceptable to both the user agent and the caller. It will 1892 | return None if no language is negotiable, otherwise the return 1893 | value is always an instance of language_tag. 1894 | 1895 | See also: RFC 3066 , and 1896 | ISO 639, links at , and 1897 | . 1898 | 1899 | """ 1900 | # Note special instructions from RFC 2616 sect. 14.1: 1901 | # "The language quality factor assigned to a language-tag by the 1902 | # Accept-Language field is the quality value of the longest 1903 | # language- range in the field that matches the language-tag." 1904 | 1905 | if _is_string(accept_header): 1906 | accept_list = parse_accept_language_header(accept_header) 1907 | else: 1908 | accept_list = accept_header 1909 | 1910 | # Possibly add in any "missing" languages that the browser may 1911 | # have forgotten to include in the list. Insure list is sorted so 1912 | # more general languages come before more specific ones. 1913 | 1914 | accept_list.sort() 1915 | all_tags = [a[0] for a in accept_list] 1916 | if assume_superiors: 1917 | to_add = [] 1918 | for langtag, qvalue, aargs in accept_list: 1919 | if len(langtag) >= 2: 1920 | for suptag in langtag.all_superiors( include_wildcard=False ): 1921 | if suptag not in all_tags: 1922 | # Add in superior at half the qvalue 1923 | to_add.append( (suptag, qvalue / 2, '') ) 1924 | all_tags.append( suptag ) 1925 | accept_list.extend( to_add ) 1926 | 1927 | # Convert server_languages to a list of language_tags 1928 | if _is_string(server_languages): 1929 | server_languages = [language_tag(server_languages)] 1930 | elif isinstance(server_languages, language_tag): 1931 | server_languages = [server_languages] 1932 | else: 1933 | server_languages = [language_tag(lang) for lang in server_languages] 1934 | 1935 | # Select the best one 1936 | best = None # tuple (langtag, qvalue, matchlen) 1937 | 1938 | for langtag, qvalue, aargs in accept_list: 1939 | # aargs is ignored for Accept-Language 1940 | if qvalue <= 0: 1941 | continue # UA doesn't accept this language 1942 | 1943 | if ignore_wildcard and langtag.is_universal_wildcard(): 1944 | continue # "*" being ignored 1945 | 1946 | for svrlang in server_languages: 1947 | # The best match is determined first by the quality factor, 1948 | # and then by the most specific match. 1949 | 1950 | matchlen = -1 # how specifically this one matches (0 is a non-match) 1951 | if svrlang.dialect_of( langtag, ignore_wildcard=ignore_wildcard ): 1952 | matchlen = len(langtag) 1953 | if not best \ 1954 | or matchlen > best[2] \ 1955 | or (matchlen == best[2] and qvalue > best[1]): 1956 | # This match is better 1957 | best = (langtag, qvalue, matchlen) 1958 | if not best: 1959 | return None 1960 | return best[0] 1961 | 1962 | 1963 | # Clean up global namespace 1964 | try: 1965 | if __emulating_set: 1966 | del set 1967 | del frozenset 1968 | except NameError: 1969 | pass 1970 | 1971 | # end of file 1972 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='airplay', 5 | 6 | version='0.0.5', 7 | 8 | description='A python client for AirPlay video', 9 | 10 | url='https://github.com/cnelson/python-airplay', 11 | 12 | author='Chris Nelson', 13 | author_email='cnelson@cnelson.org', 14 | 15 | license='Public Domain', 16 | 17 | classifiers=[ 18 | 'Development Status :: 3 - Alpha', 19 | 20 | 'Intended Audience :: Developers', 21 | 22 | 'License :: Public Domain', 23 | 24 | 'Programming Language :: Python :: 2', 25 | 'Programming Language :: Python :: 2.7', 26 | 'Programming Language :: Python :: 3', 27 | ], 28 | 29 | keywords='airplay appletv', 30 | 31 | packages=find_packages(), 32 | 33 | install_requires=[ 34 | 'zeroconf', 35 | 'click', 36 | ], 37 | 38 | tests_require=[ 39 | 'mock' 40 | ], 41 | 42 | test_suite='airplay.tests', 43 | 44 | entry_points={ 45 | 'console_scripts': [ 46 | 'airplay = airplay.cli:main' 47 | ] 48 | } 49 | ) 50 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py34,flake8 3 | 4 | [testenv] 5 | deps = mock 6 | commands = python setup.py test 7 | install_command=pip install --process-dependency-links {opts} {packages} 8 | 9 | [testenv:flake8] 10 | deps = flake8 11 | commands = flake8 12 | 13 | [flake8] 14 | max-line-length = 120 15 | ignore = E401,F403 16 | exclude = airplay/vendor/* --------------------------------------------------------------------------------