From 454f684a336737cc0c96fcbd4ec6374101b8747a Mon Sep 17 00:00:00 2001 From: "hexuan.dhx" Date: Fri, 13 May 2016 17:35:06 +0800 Subject: [PATCH 1/2] add rtmp interface --- examples/live_channel.py | 115 +++++++++++++++++ oss2/api.py | 109 ++++++++++++++++ oss2/auth.py | 25 +++- oss2/exceptions.py | 15 +++ oss2/models.py | 225 ++++++++++++++++++++++++++++++++ oss2/xml_utils.py | 123 +++++++++++++++++- tests/test_live_channel.py | 255 +++++++++++++++++++++++++++++++++++++ 7 files changed, 864 insertions(+), 3 deletions(-) create mode 100644 examples/live_channel.py create mode 100644 tests/test_live_channel.py diff --git a/examples/live_channel.py b/examples/live_channel.py new file mode 100644 index 00000000..b1986d58 --- /dev/null +++ b/examples/live_channel.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- + +import os +import shutil + +import oss2 + + +# 以下代码展示了视频直播相关接口的用法。 + + +# 首先初始化AccessKeyId、AccessKeySecret、Endpoint等信息。 +# 通过环境变量获取,或者把诸如“<你的AccessKeyId>”替换成真实的AccessKeyId等。 +# +# 以杭州区域为例,Endpoint可以是: +# http://oss-cn-hangzhou.aliyuncs.com +# https://oss-cn-hangzhou.aliyuncs.com +access_key_id = os.getenv('OSS_TEST_ACCESS_KEY_ID', '<你的AccessKeyId>') +access_key_secret = os.getenv('OSS_TEST_ACCESS_KEY_SECRET', '<你的AccessKeySecret>') +bucket_name = os.getenv('OSS_TEST_BUCKET', '<你的Bucket>') +endpoint = os.getenv('OSS_TEST_ENDPOINT', '<你的访问域名>') + + +# 确认上面的参数都填写正确了 +for param in (access_key_id, access_key_secret, bucket_name, endpoint): + assert '<' not in param, '请设置参数:' + param + + +# 创建Bucket对象,所有直播相关的接口都可以通过Bucket对象来进行 +bucket = oss2.Bucket(oss2.Auth(access_key_id, access_key_secret), endpoint, bucket_name) + + +# 创建一个直播频道。 +# 频道的名称是test_rtmp_live。直播生成的m3u8文件叫做test.m3u8,该索引文件包含3片ts文件,每片ts文件的时长为5秒(这只是一个建议值,具体的时长取决于关键帧)。 +channel_id = 'test_rtmp_live' +create_result = bucket.create_live_channel( + channel_id, + oss2.models.LiveChannelInfo( + status = 'enabled', + description = '测试使用的直播频道', + target = oss2.models.LiveChannelInfoTarget( + playlist_name = 'test.m3u8', + frag_count = 3, + frag_duration = 5))) + +# 创建直播频道之后拿到推流用的play_url(rtmp推流的url,如果Bucket不是公共读写权限那么还需要带上签名,见下文示例)和观流用的publish_url(推流产生的m3u8文件的url)。 +publish_url = create_result.publish_url +play_url = create_result.play_url + +# 创建好直播频道之后调用get_live_channel可以得到频道相关的信息。 +get_result = bucket.get_live_channel(channel_id) +print get_result.description +print get_result.status +print get_result.target.type +print get_result.target.frag_count +print get_result.target.frag_duration +print get_result.target.playlist_name + +# 拿到推流地址和观流地址之后就可以向OSS推流和观流。如果Bucket的权限不是公共读写,那么还需要对推流做签名,如果Bucket是公共读写的,那么可以直接用publish_url推流。 +# 这里的expires是一个相对时间,指的是从现在开始这次推流过期的秒数。 +# params是一个dict类型的参数,表示用户自定义的参数。所有的参数都会参与签名。 +# 拿到这个签过名的signed_url就可以使用推流工具直接进行推流,一旦连接上OSS之后超过上面的expires流也不会断掉,OSS仅在每次推流连接的时候检查expires是否合法。 +expires = 3600 +params = {'param1': 'v1', 'param2': 'v2'} +signed_url = bucket.sign_rtmp_url(publish_url, channel_id, expires, params) + +# 创建好直播频道,如果想把这个频道禁用掉(断掉正在推的流或者不再允许向一个地址推流),应该使用put_live_channel_status接口,将频道的status改成“disabled”,如果要将一个禁用状态的频道启用,那么也是调用这个接口,将status改成“enabled”。 +bucket.put_live_channel_status(channel_id, 'enabled') +bucket.put_live_channel_status(channel_id, 'disabled') + +# 对创建好的频道,可以使用list_live_channel来进行列举已达到管理的目的。 +# list_live_channel可以指定prefix、marker和max_keys这三个参数。 +# prefix可以按照前缀过滤list出来的频道。 +# marker制定了这次list开始的标记位置。 +# max_keys表示一次list出来的频道的最大数量,这个值最大不能超过1000,不填写的话默认为100。 + +prefix = '' +marker = '' +max_keys = 1000 + +while True: + list_result = bucket.list_live_channel(prefix = prefix, marker = marker, max_keys = max_keys) + marker = list_result.next_marker + print len(list_result.channels) + if not list_result.is_truncated: + break + +# 对于正在推流的频道调用get_live_channel_stat可以获得流的状态信息。 +# 如果频道正在推流,那么stat_result中的所有字段都有意义。 +# 如果频道闲置或者处于“disabled”状态,那么status为“Idle”或“Disabled”,其他字段无意义。 +stat_result = bucket.get_live_channel_stat(channel_id) +print stat_result.status +print stat_result.remote_addr +print stat_result.connected_time +print stat_result.video +print stat_result.audio + +# 如果想查看一个频道历史推流记录,可以调用get_live_channel_history。目前最多可以看到10次推流的记录 +history_result = bucket.get_live_channel_history(channel_id) +print len(history_result.records) + +# 如果希望利用直播推流产生的ts文件生成一个点播列表,可以使用post_vod_playlist方法。 +# 指定起始时间为当前时间减去60秒,结束时间为当前时间,这意味着将生成一个长度为60秒的点播视频。 +# 播放列表指定为“vod_playlist.m3u8”,也就是说这个接口调用成功之后会在OSS上生成一个名叫“vod_playlist.m3u8”的播放列表文件。 + +end_time = int(time.time()) - 60 +start_time = end_time - 3600 +playlist_name = 'vod_playlist.m3u8' +bucket.post_vod_playlist(channel_id, + playlist_name, + start_time = start_time, + end_time = end_time) + +# 如果一个直播频道已经不打算再使用了,那么可以调用delete_live_channel来删除频道。 +bucket.delete_live_channel(channel_id) diff --git a/oss2/api.py b/oss2/api.py index 85b5a782..ca1522f1 100644 --- a/oss2/api.py +++ b/oss2/api.py @@ -236,6 +236,10 @@ class Bucket(_Base): LOGGING = 'logging' REFERER = 'referer' WEBSITE = 'website' + LIVE = 'live' + COMP = 'comp' + STATUS = 'status' + VOD = 'vod' def __init__(self, auth, endpoint, bucket_name, is_cname=False, @@ -273,6 +277,23 @@ def sign_url(self, method, key, expires, headers=None, params=None): params=params) return self.auth._sign_url(req, self.bucket_name, key, expires) + def sign_rtmp_url(self, publish_url, channel_id, expires, params=None): + """生成RTMP推流的签名URL。 + + 常见的用法是生成加签的URL以供授信用户向OSS推RTMP流。 + + >>> bucket.sign_rtmp_url('test_channel', 3600, params = {'use_id': '00001', 'device_id': 'AE9789798BC01'}) + 'http://your-bucket.oss-cn-hangzhou.aliyuncs.com/test_channel?OSSAccessKeyId=9uYePR6lL468aEUp&Expires=1462787071&use_id=00001&Signature=jprQLI0kGdcvmIvkm5rTx5LFkJ4%3D&device_id=AE9789798BC01' + + :param publish_url: 创建直播频道得到的推流地址 + :param channel_id: 直播频道的名称 + :param expires: 过期时间(单位:秒),链接在当前时间再过expires秒后过期 + :param params: 需要签名的HTTP查询参数 + + :return: 签名URL。 + """ + return self.auth._sign_rtmp_url(publish_url, self.bucket_name, channel_id, expires, params) + def list_objects(self, prefix='', delimiter='', marker='', max_keys=100): """根据前缀罗列Bucket里的文件。 @@ -849,6 +870,94 @@ def delete_bucket_website(self): resp = self.__do_bucket('DELETE', params={Bucket.WEBSITE: ''}) return RequestResult(resp) + def create_live_channel(self, channel_id, input): + """创建推流直播频道 + + :param str channel_id: 要创建的live channel的名称 + :param input: LiveChannelInfo类型,包含了live channel中的描述信息 + + :return: :class:`CreateLiveChannelResult ` + """ + data = self.__convert_data(LiveChannelInfo, xml_utils.to_create_live_channel, input) + resp = self.__do_object('PUT', channel_id, data=data, params={Bucket.LIVE: ''}) + return self._parse_result(resp, xml_utils.parse_create_live_channel, CreateLiveChannelResult) + + def delete_live_channel(self, channel_id): + """删除推流直播频道 + + :param str channel_id: 要删除的live channel的名称 + """ + resp = self.__do_object('DELETE', channel_id, params={Bucket.LIVE: ''}) + return RequestResult(resp) + + def get_live_channel(self, channel_id): + """获取直播频道配置 + + :param str channel_id: 要获取的live channel的名称 + + :return: :class:`GetLiveChannelResult ` + """ + resp = self.__do_object('GET', channel_id, params={Bucket.LIVE: ''}) + return self._parse_result(resp, xml_utils.parse_get_live_channel, GetLiveChannelResult) + + def list_live_channel(self, prefix='', marker='', max_keys=100): + """列举出Bucket下所有符合条件的live channel + + param: str prefix: list时channel_id的公共前缀 + param: str marker: list时指定的起始标记 + param: int max_keys: 本次list返回live channel的最大个数 + + return: :class:`ListLiveChannelResult ` + """ + resp = self.__do_bucket('GET', params={Bucket.LIVE: '', + 'prefix': prefix, + 'marker': marker, + 'max-keys': str(max_keys)}) + return self._parse_result(resp, xml_utils.parse_list_live_channel, ListLiveChannelResult) + + def get_live_channel_stat(self, channel_id): + """获取live channel当前推流的状态 + + param str channel_id: 要获取推流状态的live channel的名称 + + return: :class:`GetLiveChannelStatResult ` + """ + resp = self.__do_object('GET', channel_id, params={Bucket.LIVE: '', Bucket.COMP: 'stat'}) + return self._parse_result(resp, xml_utils.parse_live_channel_stat, GetLiveChannelStatResult) + + def put_live_channel_status(self, channel_id, status): + """更改live channel的status,仅能在“enabled”和“disabled”两种状态中更改 + + param str channel_id: 要更改status的live channel的名称 + param str status: live channel的目标status + """ + resp = self.__do_object('PUT', channel_id, params={Bucket.LIVE: '', Bucket.STATUS: status}) + return RequestResult(resp) + + def get_live_channel_history(self, channel_id): + """获取live channel中最近的最多十次的推流记录,记录中包含推流的起止时间和远端的地址 + + param str channel_id: 要获取最近推流记录的live channel的名称 + + return: :class:`GetLiveChannelHistoryResult ` + """ + resp = self.__do_object('GET', channel_id, params={Bucket.LIVE: '', Bucket.COMP: 'history'}) + return self._parse_result(resp, xml_utils.parse_live_channel_history, GetLiveChannelHistoryResult) + + def post_vod_playlist(self, channel_id, playlist_name, start_time = 0, end_time = 0): + """根据指定的playlist name以及startTime和endTime生成一个点播的播放列表 + + param str channel_id: 要生成点播列表的live channel的名称 + param str playlist_name: 要生成点播列表m3u8文件的名称 + param int start_time: 点播的起始时间,为UNIX时间戳 + param int end_time: 点播的结束时间,为UNIX时间戳 + """ + key = channel_id + "/" + playlist_name + resp = self.__do_object('POST', key, params={Bucket.VOD: '', + 'startTime': str(start_time), + 'endTime': str(end_time)}) + return RequestResult(resp) + def _get_bucket_config(self, config): """获得Bucket某项配置,具体哪种配置由 `config` 指定。该接口直接返回 `RequestResult` 对象。 通过read()接口可以获得XML字符串。不建议使用。 diff --git a/oss2/auth.py b/oss2/auth.py index 0db813fe..c9bf3732 100644 --- a/oss2/auth.py +++ b/oss2/auth.py @@ -18,7 +18,8 @@ class Auth(object): 'acl', 'uploadId', 'uploads', 'partNumber', 'group', 'link', 'delete', 'website', 'location', 'objectInfo', 'response-expires', 'response-content-disposition', 'cors', 'lifecycle', - 'restore', 'qos', 'referer', 'append', 'position', 'security-token'] + 'restore', 'qos', 'referer', 'append', 'position', 'security-token', + 'live', 'comp', 'status', 'vod', 'startTime', 'endTime'] ) def __init__(self, access_key_id, access_key_secret): @@ -107,6 +108,28 @@ def __param_to_query(self, k, v): else: return k + def _sign_rtmp_url(self, url, bucket_name, channel_id, expires, params): + expiration_time = int(time.time()) + expires + + canonicalized_resource = "/%s/%s" % (bucket_name, channel_id) + canonicalized_params = '' + if params: + for k in params: + if k != "OSSAccessKeyId" and k != "Signature" and k!= "Expires" and k!= "SecurityToken": + canonicalized_params += '%s:%s\n' % (k, params[k]) + + p = params if params else {} + string_to_sign = str(expiration_time) + "\n" + canonicalized_params + canonicalized_resource + logging.debug('string_to_sign={0}'.format(string_to_sign)) + + h = hmac.new(to_bytes(self.secret), to_bytes(string_to_sign), hashlib.sha1) + signature = utils.b64encode_as_string(h.digest()) + + p['OSSAccessKeyId'] = self.id + p['Expires'] = str(expiration_time) + p['Signature'] = signature + + return url + '?' + '&'.join(_param_to_quoted_query(k, v) for k, v in p.items()) class AnonymousAuth(object): """用于匿名访问。 diff --git a/oss2/exceptions.py b/oss2/exceptions.py index 243e9764..95ca743f 100644 --- a/oss2/exceptions.py +++ b/oss2/exceptions.py @@ -129,6 +129,11 @@ class NoSuchCors(NotFound): code = 'NoSuchCORSConfiguration' +class NoSuchLiveChannel(NotFound): + status = 404 + code = 'NoSuchLiveChannel' + + class Conflict(ServerError): status = 409 code = '' @@ -153,6 +158,16 @@ class ObjectNotAppendable(Conflict): code = 'ObjectNotAppendable' +class ChannelStillLive(Conflict): + status = 409 + code = 'ChannelStillLive' + + +class LiveChannelDisabled(Conflict): + status = 409 + code = 'LiveChannelDisabled' + + class PreconditionFailed(ServerError): status = 412 code = 'PreconditionFailed' diff --git a/oss2/models.py b/oss2/models.py index 9176a5fe..a09bc740 100644 --- a/oss2/models.py +++ b/oss2/models.py @@ -425,3 +425,228 @@ class GetBucketCorsResult(RequestResult, BucketCors): def __init__(self, resp): RequestResult.__init__(self, resp) BucketCors.__init__(self) + + +class LiveChannelInfoTarget(object): + """Live channel中的Target节点,包含目标协议的一些参数。 + + :param type: 协议,目前仅支持HLS。 + :type type: str + + :param frag_duration: HLS协议下生成的ts文件的期望时长,单位为秒。 + :type frag_duration: int + + :param frag_count: HLS协议下m3u8文件里ts文件的数量。 + :type frag_count: int""" + + def __init__(self, + type = 'HLS', + frag_duration = 5, + frag_count = 3, + playlist_name = ''): + self.type = type + self.frag_duration = frag_duration + self.frag_count = frag_count + self.playlist_name = playlist_name + + +class LiveChannelInfo(object): + """Live channel(直播频道)配置。 + + :param status: 直播频道的状态,合法的值为"enabled"和"disabled"。 + :type type: str + + :param description: 直播频道的描述信息,最长为128字节。 + :type description: str + + :param modified: 直播频道的最后修改时间,这个字段仅在ListLiveChannel时使用。 + :type modified: str + + :param target: 直播频道的推流目标节点,包含目标协议相关的参数。 + :type class:`LiveChannelInfoTarget `""" + + def __init__(self, + status = 'enabled', + description = '', + target = None, + modified = None, + id = None): + self.status = status + self.description = description + self.target = target + self.modified = modified + + +class LiveChannelList(object): + """List直播频道的结果。 + + :param prefix: List直播频道使用的前缀。 + :type prefix: str + + :param marker: List直播频道使用的marker。 + :type marker: str + + :param max_keys: List时返回的最多的直播频道的条数。 + :type max_keys: int + + :param is_truncated: 本次List是否列举完所有的直播频道 + :type is_truncated: bool + + :param next_marker: 下一次List直播频道使用的marker。 + :type marker: str + + :param channels: List返回的直播频道列表 + :type channels: list""" + + def __init__(self, + prefix = '', + marker = '', + max_keys = 100, + is_truncated = False, + next_marker = ''): + self.prefix = prefix + self.marker = marker + self.max_keys = max_keys + self.is_truncated = is_truncated + self.next_marker = next_marker + self.channels = [] + + +class LiveChannelStatVideo(object): + """LiveStat中的Video节点。 + + :param width: 视频的宽度。 + :type width: int + + :param height: 视频的高度。 + :type height: int + + :param frame_rate: 帧率。 + :type frame_rate: int + + :param codec: 编码方式。 + :type codec: str + + :param bandwidth: 码率。 + :type bandwidth: int""" + + def __init__(self, + width = 0, + height = 0, + frame_rate = 0, + codec = '', + bandwidth = 0): + self.width = width + self.height = height + self.frame_rate = frame_rate + self.codec = codec + self.bandwidth = bandwidth + + +class LiveChannelStatAudio(object): + """LiveStat中的Audio节点。 + + :param codec: 编码方式。 + :type codec: str + + :param sample_rate: 采样率。 + :type sample_rate: int + + :param bandwidth: 码率。 + :type bandwidth: int""" + + def __init__(self, + codec = '', + sample_rate = 0, + bandwidth = 0): + self.codec = codec + self.sample_rate = sample_rate + self.bandwidth = bandwidth + + +class LiveChannelStat(object): + """LiveStat结果。 + + :param status: 直播状态。 + :type codec: str + + :param remote_addr: 客户端的地址。 + :type remote_addr: str + + :param connected_time: 本次推流开始时间。 + :type connected_time: str + + :param video: 视频描述信息。 + :type video: class:`LiveChannelStatVideo ` + + :param audio: 音频描述信息。 + :type audio: class:`LiveChannelStatAudio `""" + + def __init__(self, + status = '', + remote_addr = '', + connected_time = '', + video = None, + audio = None): + self.status = status + self.remote_addr = remote_addr + self.connected_time = connected_time + self.video = video + self.audio = audio + + +class LiveRecord(object): + """直播频道中的推流记录信息 + + :param start_time: 本次推流开始时间。 + :type start_time: str + + :param end_time: 本次推流结束时间。 + :type end_time: str + + :param remote_addr: 推流时客户端的地址。 + :type remote_addr: str""" + + def __init__(self, + start_time = '', + end_time = '', + remote_addr = ''): + self.start_time = start_time + self.end_time = end_time + self.remote_addr = remote_addr + + +class LiveChannelHistory(object): + """直播频道下的推流记录。""" + + def __init__(self): + self.records = [] + + +class CreateLiveChannelResult(RequestResult, LiveChannelInfo): + def __init__(self, resp): + RequestResult.__init__(self, resp) + LiveChannelInfo.__init__(self) + + +class GetLiveChannelResult(RequestResult, LiveChannelInfo): + def __init__(self, resp): + RequestResult.__init__(self, resp) + LiveChannelInfo.__init__(self) + + +class ListLiveChannelResult(RequestResult, LiveChannelList): + def __init__(self, resp): + RequestResult.__init__(self, resp) + LiveChannelList.__init__(self) + + +class GetLiveChannelStatResult(RequestResult, LiveChannelStat): + def __init__(self, resp): + RequestResult.__init__(self, resp) + LiveChannelStat.__init__(self) + +class GetLiveChannelHistoryResult(RequestResult, LiveChannelHistory): + def __init__(self, resp): + RequestResult.__init__(self, resp) + LiveChannelHistory.__init__(self) diff --git a/oss2/xml_utils.py b/oss2/xml_utils.py index fa3afba6..1a493fe9 100644 --- a/oss2/xml_utils.py +++ b/oss2/xml_utils.py @@ -20,7 +20,10 @@ MultipartUploadInfo, LifecycleRule, LifecycleExpiration, - CorsRule) + CorsRule, + LiveChannelInfoTarget, + LiveChannelInfo, + LiveRecord) from .compat import urlunquote, to_unicode, to_string from .utils import iso8601_to_unixtime, date_to_iso8601, iso8601_to_date @@ -83,6 +86,8 @@ def _add_node_list(parent, tag, entries): def _add_text_child(parent, tag, text): ElementTree.SubElement(parent, tag).text = to_unicode(text) +def _add_node_child(parent, tag): + return ElementTree.SubElement(parent, tag) def parse_list_objects(result, body): root = ElementTree.fromstring(body) @@ -229,6 +234,106 @@ def parse_get_bucket_websiste(result, body): return result +def parse_create_live_channel(result, body): + root = ElementTree.fromstring(body) + + result.play_url = _find_tag(root, 'PlayUrls/Url') + result.publish_url = _find_tag(root, 'PublishUrls/Url') + + return result + + +def parse_get_live_channel(result, body): + root = ElementTree.fromstring(body) + + result.status = _find_tag(root, 'Status') + result.description = _find_tag(root, 'Description') + + target = LiveChannelInfoTarget() + target.type = _find_tag(root, 'Target/Type') + target.frag_duration = _find_tag(root, 'Target/FragDuration') + target.frag_count = _find_tag(root, 'Target/FragCount') + target.playlist_name = _find_tag(root, 'Target/PlaylistName') + + result.target = target + + return result + + +def parse_list_live_channel(result, body): + root = ElementTree.fromstring(body) + + result.prefix = _find_tag(root, 'Prefix') + result.marker = _find_tag(root, 'Marker') + result.max_keys = _find_int(root, 'MaxKeys') + result.is_truncated = _find_bool(root, 'IsTruncated') + result.next_marker = _find_tag(root, 'NextMarker') + + channels = root.findall('LiveChannel') + tmp = LiveChannelInfo() + for channel in channels: + tmp.id = _find_tag(channel, 'Id') + tmp.description = _find_tag(channel, 'Description') + tmp.status = _find_tag(channel, 'Status') + tmp.modified = _find_tag(channel, 'LastModified') + tmp.play_url = _find_tag(channel, 'PlayUrls/Url') + tmp.publish_url = _find_tag(channel, 'PublishUrls/Url') + + result.channels.append(tmp) + + return result + + +def parse_stat_video(video_node, video): + video.width = _find_int(video_node, 'Width') + video.height = _find_int(video_node, 'Height') + video.frame_rate = _find_int(video_node, 'FrameRate') + video.bandwidth = _find_int(video_node, 'Bandwidth') + video.codec = _find_tag(video_node, 'Codec') + + +def parse_stat_audio(audio_node, audio): + audio.bandwidth = _find_int(audio_node, 'Bandwidth') + audio.sample_rate = _find_int(audio_node, 'SampleRate') + audio.codec = _find(audio_node, 'Codec') + + +def parse_live_channel_stat(result, body): + root = ElementTree.fromstring(body) + + result.status = _find_tag(root, 'Status') + if root.find('RemoteAddr'): + result.remote_addr = _find_tag(root, 'RemoteAddr') + result.connected_time = int(_find_tag(root, 'ConnectedTime')) + + video_node = root.find('Video') + audio_node = root.find('Audio') + + if video_node is not None: + result.video = LiveChannelStatVideo() + parse_stat_video(video_node, result.video) + if audio_node is not None: + result.audio = LiveChannelStatAudio() + parse_stat_audio(audio_node, result.audio) + + return result + + +def parse_live_channel_history(result, body): + root = ElementTree.fromstring(body) + + records = root.findall('LiveRecord') + tmp = LiveRecord() + for record in records: + tmp.start_time = _find_tag(record, 'StartTime') + tmp.end_time = _find_tag(record, 'EndTime') + tmp.remote_addr = _find_tag(record, 'RemoteAddr') + + root.records.append(tmp) + + return result + + def parse_lifecycle_expiration(expiration_node): if expiration_node is None: return None @@ -369,4 +474,18 @@ def to_put_bucket_cors(bucket_cors): if rule.max_age_seconds is not None: _add_text_child(rule_node, 'MaxAgeSeconds', str(rule.max_age_seconds)) - return _node_to_string(root) \ No newline at end of file + return _node_to_string(root) + +def to_create_live_channel(live_channel): + root = ElementTree.Element('LiveChannelConfiguration') + + _add_text_child(root, 'Description', live_channel.description) + _add_text_child(root, 'Status', live_channel.status) + target_node = _add_node_child(root, 'Target') + + _add_text_child(target_node, 'Type', live_channel.target.type) + _add_text_child(target_node, 'FragDuration', str(live_channel.target.frag_duration)) + _add_text_child(target_node, 'FragCount', str(live_channel.target.frag_count)) + _add_text_child(target_node, 'PlaylistName', str(live_channel.target.playlist_name)) + + return _node_to_string(root) diff --git a/tests/test_live_channel.py b/tests/test_live_channel.py new file mode 100644 index 00000000..39d259d1 --- /dev/null +++ b/tests/test_live_channel.py @@ -0,0 +1,255 @@ +# -*- coding: utf-8 -*- + +import unittest +import datetime +import time +import os, sys +parentdir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, parentdir) +import oss2 + +from common import * +from oss2.exceptions import * + + +class TestLiveChannel(unittest.TestCase): + def __init__(self, *args, **kwargs): + super(TestLiveChannel, self).__init__(*args, **kwargs) + self.bucket = None + + def setUp(self): + self.bucket = oss2.Bucket(oss2.Auth(OSS_ID, OSS_SECRET), OSS_ENDPOINT, OSS_BUCKET) + self.bucket.create_bucket() + + def _get_play_url(self, bucket_name, channel_id, playlist_name): + return 'http://%s.%s/%s/%s' % (bucket_name, OSS_ENDPOINT, channel_id, playlist_name if playlist_name else 'playlist.m3u8') + + def _get_publish_url(self, bucket_name, channel_id): + return 'rtmp://%s.%s/live/%s' % (bucket_name, OSS_ENDPOINT, channel_id) + + def _get_fixed_number(self, size, n): + nstr = str(n) + if size > len(nstr): + nstr = (size - len(nstr)) * '0' + nstr + return nstr + + def _assert_list_result(self, + result, + marker = '', + prefix = '', + next_marker = '', + max_keys = 0, + is_truncated = False, + return_count = 0): + self.assertEqual(result.prefix, prefix) + self.assertEqual(result.marker, marker) + self.assertEqual(result.next_marker, next_marker) + self.assertEqual(result.max_keys, max_keys) + self.assertEqual(result.is_truncated, is_truncated) + self.assertEqual(len(result.channels), return_count) + + def test_create_live_channel(self): + auth = oss2.Auth(OSS_ID, OSS_SECRET) + bucket_name = random_string(63).lower() + bucket = oss2.Bucket(auth, OSS_ENDPOINT, bucket_name) + bucket.create_bucket(oss2.BUCKET_ACL_PRIVATE) + + channel_id = 'rtmp-channel' + playlist_name = 'test.m3u8' + channel_target = oss2.models.LiveChannelInfoTarget(playlist_name = playlist_name) + channel_info = oss2.models.LiveChannelInfo(target = channel_target) + create_result = bucket.create_live_channel(channel_id, channel_info) + + self.assertEqual(create_result.play_url, + self._get_play_url(bucket_name, channel_id, playlist_name)) + self.assertEqual(create_result.publish_url, + self._get_publish_url(bucket_name, channel_id)) + + delete_result = bucket.delete_live_channel(channel_id) + bucket.delete_bucket() + self.assertRaises(oss2.exceptions.NoSuchBucket, bucket.delete_bucket) + + def test_get_live_channel(self): + auth = oss2.Auth(OSS_ID, OSS_SECRET) + bucket = oss2.Bucket(auth, OSS_ENDPOINT, random_string(63).lower()) + bucket.create_bucket(oss2.BUCKET_ACL_PRIVATE) + + channel_id = 'rtmp-channel' + + self.assertRaises(NoSuchLiveChannel, bucket.get_live_channel, channel_id) + + channel_target = oss2.models.LiveChannelInfoTarget(playlist_name = 'test.m3u8') + channel_info = oss2.models.LiveChannelInfo(target = channel_target) + create_result = bucket.create_live_channel(channel_id, channel_info) + + get_result = bucket.get_live_channel(channel_id) + self.assertEqual(get_result.description, channel_info.description) + self.assertEqual(get_result.status, channel_info.status) + self.assertEqual(get_result.target.type, channel_target.type) + self.assertEqual(get_result.target.frag_duration, str(channel_target.frag_duration)) + self.assertEqual(get_result.target.frag_count, str(channel_target.frag_count)) + self.assertEqual(get_result.target.playlist_name, channel_target.playlist_name) + + bucket.delete_live_channel(channel_id) + bucket.delete_bucket() + self.assertRaises(oss2.exceptions.NoSuchBucket, bucket.delete_bucket) + + def test_list_live_channel(self): + auth = oss2.Auth(OSS_ID, OSS_SECRET) + bucket = oss2.Bucket(auth, OSS_ENDPOINT, random_string(63).lower()) + bucket.create_bucket(oss2.BUCKET_ACL_PRIVATE) + + channel_id = 'rtmp-channel' + self.assertRaises(NoSuchLiveChannel, bucket.get_live_channel, channel_id) + + list_result = bucket.list_live_channel() + self._assert_list_result(list_result, + prefix = '', + marker = '', + next_marker = '', + max_keys = 100, + is_truncated = False, + return_count = 0) + + channel_id_list = [] + prefix1 = random_string(5) + channel_target = oss2.models.LiveChannelInfoTarget(playlist_name = 'test.m3u8') + channel_info = oss2.models.LiveChannelInfo(target = channel_target) + for index in xrange(0, 200): + channel_id_list.append(prefix1 + self._get_fixed_number(10, index)) + bucket.create_live_channel(channel_id_list[index], channel_info) + + list_result = bucket.list_live_channel() + next_marker = prefix1 + self._get_fixed_number(10, 99) + self._assert_list_result(list_result, + prefix = '', + marker = '', + next_marker = next_marker, + max_keys = 100, + is_truncated = True, + return_count = 100) + + prefix2 = random_string(5) + list_result = bucket.list_live_channel(prefix = prefix2) + self._assert_list_result(list_result, + prefix = prefix2, + marker = '', + next_marker = '', + max_keys = 100, + is_truncated = False, + return_count = 0) + + marker = prefix1 + self._get_fixed_number(10, 100) + list_result = bucket.list_live_channel( + prefix = prefix1, + marker = marker) + self._assert_list_result(list_result, + prefix = prefix1, + marker = marker, + next_marker = '', + max_keys = 100, + is_truncated = False, + return_count = 99) + + max_keys = 1000 + list_result = bucket.list_live_channel(max_keys = max_keys) + self._assert_list_result(list_result, + prefix = '', + marker = '', + next_marker = '', + max_keys = max_keys, + is_truncated = False, + return_count = 200) + + for channel_id in channel_id_list: + bucket.delete_live_channel(channel_id) + bucket.delete_bucket() + self.assertRaises(oss2.exceptions.NoSuchBucket, bucket.delete_bucket) + + def test_get_live_channel_stat(self): + auth = oss2.Auth(OSS_ID, OSS_SECRET) + bucket = oss2.Bucket(auth, OSS_ENDPOINT, random_string(63).lower()) + bucket.create_bucket(oss2.BUCKET_ACL_PRIVATE) + + channel_id = 'rtmp-channel' + _playlist_name = 'test.m3u8' + channel_target = oss2.models.LiveChannelInfoTarget(playlist_name = _playlist_name) + channel_info = oss2.models.LiveChannelInfo(target = channel_target) + create_result = bucket.create_live_channel(channel_id, channel_info) + + get_stat_result = bucket.get_live_channel_stat(channel_id) + self.assertEqual(get_stat_result.status, 'Idle') + + bucket.delete_live_channel(channel_id) + bucket.delete_bucket() + self.assertRaises(oss2.exceptions.NoSuchBucket, bucket.delete_bucket) + + def test_put_live_channel_status(self): + auth = oss2.Auth(OSS_ID, OSS_SECRET) + bucket = oss2.Bucket(auth, OSS_ENDPOINT, random_string(63).lower()) + bucket.create_bucket(oss2.BUCKET_ACL_PRIVATE) + + channel_id = 'rtmp-channel' + channel_target = oss2.models.LiveChannelInfoTarget() + channel_info = oss2.models.LiveChannelInfo(target = channel_target) + create_result = bucket.create_live_channel(channel_id, channel_info) + + get_result = bucket.get_live_channel(channel_id) + self.assertEqual(get_result.status, 'enabled') + + bucket.put_live_channel_status(channel_id, 'disabled') + + get_result = bucket.get_live_channel(channel_id) + self.assertEqual(get_result.status, 'disabled') + + bucket.delete_live_channel(channel_id) + bucket.delete_bucket() + self.assertRaises(oss2.exceptions.NoSuchBucket, bucket.delete_bucket) + + def test_get_live_channel_history(self): + auth = oss2.Auth(OSS_ID, OSS_SECRET) + bucket = oss2.Bucket(auth, OSS_ENDPOINT, random_string(63).lower()) + bucket.create_bucket(oss2.BUCKET_ACL_PRIVATE) + + channel_id = 'rtmp-channel' + channel_target = oss2.models.LiveChannelInfoTarget() + channel_info = oss2.models.LiveChannelInfo(target = channel_target) + create_result = bucket.create_live_channel(channel_id, channel_info) + + get_result = bucket.get_live_channel_history(channel_id) + self.assertEqual(len(get_result.records), 0) + + bucket.delete_live_channel(channel_id) + bucket.delete_bucket() + self.assertRaises(oss2.exceptions.NoSuchBucket, bucket.delete_bucket) + + def test_post_vod_playlist(self): + auth = oss2.Auth(OSS_ID, OSS_SECRET) + bucket = oss2.Bucket(auth, OSS_ENDPOINT, random_string(63).lower()) + bucket.create_bucket(oss2.BUCKET_ACL_PRIVATE) + + channel_id = 'rtmp-channel' + channel_target = oss2.models.LiveChannelInfoTarget() + channel_info = oss2.models.LiveChannelInfo(target = channel_target) + create_result = bucket.create_live_channel(channel_id, channel_info) + + # publish rtmp stream here, generate some ts file on oss. + + end_time = int(time.time()) - 60 + start_time = end_time - 3600 + playlist_name = 'vod_playlist.m3u8' + + # throw exception because no ts file been generated. + self.assertRaises(oss2.exceptions.InvalidArgument, + bucket.post_vod_playlist, + channel_id, + playlist_name, + start_time = start_time, + end_time = end_time) + + bucket.delete_live_channel(channel_id) + bucket.delete_bucket() + self.assertRaises(oss2.exceptions.NoSuchBucket, bucket.delete_bucket) + +if __name__ == '__main__': + unittest.main() From 8676ca7ae124d114536ea77049b41dd92d409be1 Mon Sep 17 00:00:00 2001 From: "hexuan.dhx" Date: Fri, 13 May 2016 17:35:06 +0800 Subject: [PATCH 2/2] add rtmp interface --- examples/live_channel.py | 108 ++++++++++++++++ oss2/__init__.py | 3 +- oss2/api.py | 109 ++++++++++++++++ oss2/auth.py | 25 +++- oss2/exceptions.py | 15 +++ oss2/iterators.py | 26 ++++ oss2/models.py | 226 ++++++++++++++++++++++++++++++++ oss2/xml_utils.py | 123 +++++++++++++++++- tests/test_iterator.py | 26 +++- tests/test_live_channel.py | 255 +++++++++++++++++++++++++++++++++++++ 10 files changed, 911 insertions(+), 5 deletions(-) create mode 100644 examples/live_channel.py create mode 100644 tests/test_live_channel.py diff --git a/examples/live_channel.py b/examples/live_channel.py new file mode 100644 index 00000000..5e05f8df --- /dev/null +++ b/examples/live_channel.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- + +import os +import shutil + +import oss2 + + +# 以下代码展示了视频直播相关接口的用法。 + + +# 首先初始化AccessKeyId、AccessKeySecret、Endpoint等信息。 +# 通过环境变量获取,或者把诸如“<你的AccessKeyId>”替换成真实的AccessKeyId等。 +# +# 以杭州区域为例,Endpoint可以是: +# http://oss-cn-hangzhou.aliyuncs.com +# https://oss-cn-hangzhou.aliyuncs.com +access_key_id = os.getenv('OSS_TEST_ACCESS_KEY_ID', '<你的AccessKeyId>') +access_key_secret = os.getenv('OSS_TEST_ACCESS_KEY_SECRET', '<你的AccessKeySecret>') +bucket_name = os.getenv('OSS_TEST_BUCKET', '<你的Bucket>') +endpoint = os.getenv('OSS_TEST_ENDPOINT', '<你的访问域名>') + + +# 确认上面的参数都填写正确了 +for param in (access_key_id, access_key_secret, bucket_name, endpoint): + assert '<' not in param, '请设置参数:' + param + + +# 创建Bucket对象,所有直播相关的接口都可以通过Bucket对象来进行 +bucket = oss2.Bucket(oss2.Auth(access_key_id, access_key_secret), endpoint, bucket_name) + + +# 创建一个直播频道。 +# 频道的名称是test_rtmp_live。直播生成的m3u8文件叫做test.m3u8,该索引文件包含3片ts文件,每片ts文件的时长为5秒(这只是一个建议值,具体的时长取决于关键帧)。 +channel_id = 'test_rtmp_live' +create_result = bucket.create_live_channel( + channel_id, + oss2.models.LiveChannelInfo( + status = 'enabled', + description = '测试使用的直播频道', + target = oss2.models.LiveChannelInfoTarget( + playlist_name = 'test.m3u8', + frag_count = 3, + frag_duration = 5))) + +# 创建直播频道之后拿到推流用的play_url(rtmp推流的url,如果Bucket不是公共读写权限那么还需要带上签名,见下文示例)和观流用的publish_url(推流产生的m3u8文件的url)。 +publish_url = create_result.publish_url +play_url = create_result.play_url + +# 创建好直播频道之后调用get_live_channel可以得到频道相关的信息。 +get_result = bucket.get_live_channel(channel_id) +print(get_result.description) +print(get_result.status) +print(get_result.target.type) +print(get_result.target.frag_count) +print(get_result.target.frag_duration) +print(get_result.target.playlist_name) + +# 拿到推流地址和观流地址之后就可以向OSS推流和观流。如果Bucket的权限不是公共读写,那么还需要对推流做签名,如果Bucket是公共读写的,那么可以直接用publish_url推流。 +# 这里的expires是一个相对时间,指的是从现在开始这次推流过期的秒数。 +# params是一个dict类型的参数,表示用户自定义的参数。所有的参数都会参与签名。 +# 拿到这个签过名的signed_url就可以使用推流工具直接进行推流,一旦连接上OSS之后超过上面的expires流也不会断掉,OSS仅在每次推流连接的时候检查expires是否合法。 +expires = 3600 +params = {'param1': 'v1', 'param2': 'v2'} +signed_url = bucket.sign_rtmp_url(publish_url, channel_id, expires, params) + +# 创建好直播频道,如果想把这个频道禁用掉(断掉正在推的流或者不再允许向一个地址推流),应该使用put_live_channel_status接口,将频道的status改成“disabled”,如果要将一个禁用状态的频道启用,那么也是调用这个接口,将status改成“enabled”。 +bucket.put_live_channel_status(channel_id, 'enabled') +bucket.put_live_channel_status(channel_id, 'disabled') + +# 对创建好的频道,可以使用LiveChannelIterator来进行列举已达到管理的目的。 +# prefix可以按照前缀过滤list出来的频道。 +# max_keys表示迭代器内部一次list出来的频道的最大数量,这个值最大不能超过1000,不填写的话默认为100。 + +prefix = '' +max_keys = 1000 + +for info in oss2.LiveChannelIterator(self.bucket, prefix, max_keys=max_keys): + print(info.id) + +# 对于正在推流的频道调用get_live_channel_stat可以获得流的状态信息。 +# 如果频道正在推流,那么stat_result中的所有字段都有意义。 +# 如果频道闲置或者处于“disabled”状态,那么status为“Idle”或“Disabled”,其他字段无意义。 +stat_result = bucket.get_live_channel_stat(channel_id) +print(stat_result.status) +print(stat_result.remote_addr) +print(stat_result.connected_time) +print(stat_result.video) +print(stat_result.audio) + +# 如果想查看一个频道历史推流记录,可以调用get_live_channel_history。目前最多可以看到10次推流的记录 +history_result = bucket.get_live_channel_history(channel_id) +print(len(history_result.records)) + +# 如果希望利用直播推流产生的ts文件生成一个点播列表,可以使用post_vod_playlist方法。 +# 指定起始时间为当前时间减去60秒,结束时间为当前时间,这意味着将生成一个长度为60秒的点播视频。 +# 播放列表指定为“vod_playlist.m3u8”,也就是说这个接口调用成功之后会在OSS上生成一个名叫“vod_playlist.m3u8”的播放列表文件。 + +end_time = int(time.time()) - 60 +start_time = end_time - 3600 +playlist_name = 'vod_playlist.m3u8' +bucket.post_vod_playlist(channel_id, + playlist_name, + start_time = start_time, + end_time = end_time) + +# 如果一个直播频道已经不打算再使用了,那么可以调用delete_live_channel来删除频道。 +bucket.delete_live_channel(channel_id) diff --git a/oss2/__init__.py b/oss2/__init__.py index 4a2165f1..f9b30db1 100644 --- a/oss2/__init__.py +++ b/oss2/__init__.py @@ -8,7 +8,8 @@ from .iterators import (BucketIterator, ObjectIterator, - MultipartUploadIterator, ObjectUploadIterator, PartIterator) + MultipartUploadIterator, ObjectUploadIterator, + PartIterator, LiveChannelIterator) from .resumable import resumable_upload, resumable_download, ResumableStore, ResumableDownloadStore, determine_part_size diff --git a/oss2/api.py b/oss2/api.py index 85b5a782..ca1522f1 100644 --- a/oss2/api.py +++ b/oss2/api.py @@ -236,6 +236,10 @@ class Bucket(_Base): LOGGING = 'logging' REFERER = 'referer' WEBSITE = 'website' + LIVE = 'live' + COMP = 'comp' + STATUS = 'status' + VOD = 'vod' def __init__(self, auth, endpoint, bucket_name, is_cname=False, @@ -273,6 +277,23 @@ def sign_url(self, method, key, expires, headers=None, params=None): params=params) return self.auth._sign_url(req, self.bucket_name, key, expires) + def sign_rtmp_url(self, publish_url, channel_id, expires, params=None): + """生成RTMP推流的签名URL。 + + 常见的用法是生成加签的URL以供授信用户向OSS推RTMP流。 + + >>> bucket.sign_rtmp_url('test_channel', 3600, params = {'use_id': '00001', 'device_id': 'AE9789798BC01'}) + 'http://your-bucket.oss-cn-hangzhou.aliyuncs.com/test_channel?OSSAccessKeyId=9uYePR6lL468aEUp&Expires=1462787071&use_id=00001&Signature=jprQLI0kGdcvmIvkm5rTx5LFkJ4%3D&device_id=AE9789798BC01' + + :param publish_url: 创建直播频道得到的推流地址 + :param channel_id: 直播频道的名称 + :param expires: 过期时间(单位:秒),链接在当前时间再过expires秒后过期 + :param params: 需要签名的HTTP查询参数 + + :return: 签名URL。 + """ + return self.auth._sign_rtmp_url(publish_url, self.bucket_name, channel_id, expires, params) + def list_objects(self, prefix='', delimiter='', marker='', max_keys=100): """根据前缀罗列Bucket里的文件。 @@ -849,6 +870,94 @@ def delete_bucket_website(self): resp = self.__do_bucket('DELETE', params={Bucket.WEBSITE: ''}) return RequestResult(resp) + def create_live_channel(self, channel_id, input): + """创建推流直播频道 + + :param str channel_id: 要创建的live channel的名称 + :param input: LiveChannelInfo类型,包含了live channel中的描述信息 + + :return: :class:`CreateLiveChannelResult ` + """ + data = self.__convert_data(LiveChannelInfo, xml_utils.to_create_live_channel, input) + resp = self.__do_object('PUT', channel_id, data=data, params={Bucket.LIVE: ''}) + return self._parse_result(resp, xml_utils.parse_create_live_channel, CreateLiveChannelResult) + + def delete_live_channel(self, channel_id): + """删除推流直播频道 + + :param str channel_id: 要删除的live channel的名称 + """ + resp = self.__do_object('DELETE', channel_id, params={Bucket.LIVE: ''}) + return RequestResult(resp) + + def get_live_channel(self, channel_id): + """获取直播频道配置 + + :param str channel_id: 要获取的live channel的名称 + + :return: :class:`GetLiveChannelResult ` + """ + resp = self.__do_object('GET', channel_id, params={Bucket.LIVE: ''}) + return self._parse_result(resp, xml_utils.parse_get_live_channel, GetLiveChannelResult) + + def list_live_channel(self, prefix='', marker='', max_keys=100): + """列举出Bucket下所有符合条件的live channel + + param: str prefix: list时channel_id的公共前缀 + param: str marker: list时指定的起始标记 + param: int max_keys: 本次list返回live channel的最大个数 + + return: :class:`ListLiveChannelResult ` + """ + resp = self.__do_bucket('GET', params={Bucket.LIVE: '', + 'prefix': prefix, + 'marker': marker, + 'max-keys': str(max_keys)}) + return self._parse_result(resp, xml_utils.parse_list_live_channel, ListLiveChannelResult) + + def get_live_channel_stat(self, channel_id): + """获取live channel当前推流的状态 + + param str channel_id: 要获取推流状态的live channel的名称 + + return: :class:`GetLiveChannelStatResult ` + """ + resp = self.__do_object('GET', channel_id, params={Bucket.LIVE: '', Bucket.COMP: 'stat'}) + return self._parse_result(resp, xml_utils.parse_live_channel_stat, GetLiveChannelStatResult) + + def put_live_channel_status(self, channel_id, status): + """更改live channel的status,仅能在“enabled”和“disabled”两种状态中更改 + + param str channel_id: 要更改status的live channel的名称 + param str status: live channel的目标status + """ + resp = self.__do_object('PUT', channel_id, params={Bucket.LIVE: '', Bucket.STATUS: status}) + return RequestResult(resp) + + def get_live_channel_history(self, channel_id): + """获取live channel中最近的最多十次的推流记录,记录中包含推流的起止时间和远端的地址 + + param str channel_id: 要获取最近推流记录的live channel的名称 + + return: :class:`GetLiveChannelHistoryResult ` + """ + resp = self.__do_object('GET', channel_id, params={Bucket.LIVE: '', Bucket.COMP: 'history'}) + return self._parse_result(resp, xml_utils.parse_live_channel_history, GetLiveChannelHistoryResult) + + def post_vod_playlist(self, channel_id, playlist_name, start_time = 0, end_time = 0): + """根据指定的playlist name以及startTime和endTime生成一个点播的播放列表 + + param str channel_id: 要生成点播列表的live channel的名称 + param str playlist_name: 要生成点播列表m3u8文件的名称 + param int start_time: 点播的起始时间,为UNIX时间戳 + param int end_time: 点播的结束时间,为UNIX时间戳 + """ + key = channel_id + "/" + playlist_name + resp = self.__do_object('POST', key, params={Bucket.VOD: '', + 'startTime': str(start_time), + 'endTime': str(end_time)}) + return RequestResult(resp) + def _get_bucket_config(self, config): """获得Bucket某项配置,具体哪种配置由 `config` 指定。该接口直接返回 `RequestResult` 对象。 通过read()接口可以获得XML字符串。不建议使用。 diff --git a/oss2/auth.py b/oss2/auth.py index 0db813fe..c9bf3732 100644 --- a/oss2/auth.py +++ b/oss2/auth.py @@ -18,7 +18,8 @@ class Auth(object): 'acl', 'uploadId', 'uploads', 'partNumber', 'group', 'link', 'delete', 'website', 'location', 'objectInfo', 'response-expires', 'response-content-disposition', 'cors', 'lifecycle', - 'restore', 'qos', 'referer', 'append', 'position', 'security-token'] + 'restore', 'qos', 'referer', 'append', 'position', 'security-token', + 'live', 'comp', 'status', 'vod', 'startTime', 'endTime'] ) def __init__(self, access_key_id, access_key_secret): @@ -107,6 +108,28 @@ def __param_to_query(self, k, v): else: return k + def _sign_rtmp_url(self, url, bucket_name, channel_id, expires, params): + expiration_time = int(time.time()) + expires + + canonicalized_resource = "/%s/%s" % (bucket_name, channel_id) + canonicalized_params = '' + if params: + for k in params: + if k != "OSSAccessKeyId" and k != "Signature" and k!= "Expires" and k!= "SecurityToken": + canonicalized_params += '%s:%s\n' % (k, params[k]) + + p = params if params else {} + string_to_sign = str(expiration_time) + "\n" + canonicalized_params + canonicalized_resource + logging.debug('string_to_sign={0}'.format(string_to_sign)) + + h = hmac.new(to_bytes(self.secret), to_bytes(string_to_sign), hashlib.sha1) + signature = utils.b64encode_as_string(h.digest()) + + p['OSSAccessKeyId'] = self.id + p['Expires'] = str(expiration_time) + p['Signature'] = signature + + return url + '?' + '&'.join(_param_to_quoted_query(k, v) for k, v in p.items()) class AnonymousAuth(object): """用于匿名访问。 diff --git a/oss2/exceptions.py b/oss2/exceptions.py index 243e9764..95ca743f 100644 --- a/oss2/exceptions.py +++ b/oss2/exceptions.py @@ -129,6 +129,11 @@ class NoSuchCors(NotFound): code = 'NoSuchCORSConfiguration' +class NoSuchLiveChannel(NotFound): + status = 404 + code = 'NoSuchLiveChannel' + + class Conflict(ServerError): status = 409 code = '' @@ -153,6 +158,16 @@ class ObjectNotAppendable(Conflict): code = 'ObjectNotAppendable' +class ChannelStillLive(Conflict): + status = 409 + code = 'ChannelStillLive' + + +class LiveChannelDisabled(Conflict): + status = 409 + code = 'LiveChannelDisabled' + + class PreconditionFailed(ServerError): status = 412 code = 'PreconditionFailed' diff --git a/oss2/iterators.py b/oss2/iterators.py index 2cda7645..bb1f1365 100644 --- a/oss2/iterators.py +++ b/oss2/iterators.py @@ -213,3 +213,29 @@ def _fetch(self): return result.is_truncated, result.next_marker + +class LiveChannelIterator(_BaseIterator): + """遍历Bucket里文件的迭代器。 + + 每次迭代返回的是 :class:`LiveChannelInfo ` 对象。 + + :param bucket: :class:`Bucket ` 对象 + :param prefix: 只列举匹配该前缀的文件 + :param marker: 分页符 + :param max_keys: 每次调用 `list_live_channel` 时的max_keys参数。注意迭代器返回的数目可能会大于该值。 + """ + def __init__(self, bucket, prefix='', marker='', max_keys=100, max_retries=None): + super(LiveChannelIterator, self).__init__(marker, max_retries) + + self.bucket = bucket + self.prefix = prefix + self.max_keys = max_keys + + def _fetch(self): + result = self.bucket.list_live_channel(prefix=self.prefix, + marker=self.next_marker, + max_keys=self.max_keys) + self.entries = result.channels + + return result.is_truncated, result.next_marker + diff --git a/oss2/models.py b/oss2/models.py index 9176a5fe..a44c808c 100644 --- a/oss2/models.py +++ b/oss2/models.py @@ -425,3 +425,229 @@ class GetBucketCorsResult(RequestResult, BucketCors): def __init__(self, resp): RequestResult.__init__(self, resp) BucketCors.__init__(self) + + +class LiveChannelInfoTarget(object): + """Live channel中的Target节点,包含目标协议的一些参数。 + + :param type: 协议,目前仅支持HLS。 + :type type: str + + :param frag_duration: HLS协议下生成的ts文件的期望时长,单位为秒。 + :type frag_duration: int + + :param frag_count: HLS协议下m3u8文件里ts文件的数量。 + :type frag_count: int""" + + def __init__(self, + type = 'HLS', + frag_duration = 5, + frag_count = 3, + playlist_name = ''): + self.type = type + self.frag_duration = frag_duration + self.frag_count = frag_count + self.playlist_name = playlist_name + + +class LiveChannelInfo(object): + """Live channel(直播频道)配置。 + + :param status: 直播频道的状态,合法的值为"enabled"和"disabled"。 + :type type: str + + :param description: 直播频道的描述信息,最长为128字节。 + :type description: str + + :param modified: 直播频道的最后修改时间,这个字段仅在ListLiveChannel时使用。 + :type modified: str + + :param target: 直播频道的推流目标节点,包含目标协议相关的参数。 + :type class:`LiveChannelInfoTarget `""" + + def __init__(self, + status = 'enabled', + description = '', + target = None, + modified = None, + id = None): + self.status = status + self.description = description + self.target = target + self.modified = modified + self.id = id + + +class LiveChannelList(object): + """List直播频道的结果。 + + :param prefix: List直播频道使用的前缀。 + :type prefix: str + + :param marker: List直播频道使用的marker。 + :type marker: str + + :param max_keys: List时返回的最多的直播频道的条数。 + :type max_keys: int + + :param is_truncated: 本次List是否列举完所有的直播频道 + :type is_truncated: bool + + :param next_marker: 下一次List直播频道使用的marker。 + :type marker: str + + :param channels: List返回的直播频道列表 + :type channels: list""" + + def __init__(self, + prefix = '', + marker = '', + max_keys = 100, + is_truncated = False, + next_marker = ''): + self.prefix = prefix + self.marker = marker + self.max_keys = max_keys + self.is_truncated = is_truncated + self.next_marker = next_marker + self.channels = [] + + +class LiveChannelStatVideo(object): + """LiveStat中的Video节点。 + + :param width: 视频的宽度。 + :type width: int + + :param height: 视频的高度。 + :type height: int + + :param frame_rate: 帧率。 + :type frame_rate: int + + :param codec: 编码方式。 + :type codec: str + + :param bandwidth: 码率。 + :type bandwidth: int""" + + def __init__(self, + width = 0, + height = 0, + frame_rate = 0, + codec = '', + bandwidth = 0): + self.width = width + self.height = height + self.frame_rate = frame_rate + self.codec = codec + self.bandwidth = bandwidth + + +class LiveChannelStatAudio(object): + """LiveStat中的Audio节点。 + + :param codec: 编码方式。 + :type codec: str + + :param sample_rate: 采样率。 + :type sample_rate: int + + :param bandwidth: 码率。 + :type bandwidth: int""" + + def __init__(self, + codec = '', + sample_rate = 0, + bandwidth = 0): + self.codec = codec + self.sample_rate = sample_rate + self.bandwidth = bandwidth + + +class LiveChannelStat(object): + """LiveStat结果。 + + :param status: 直播状态。 + :type codec: str + + :param remote_addr: 客户端的地址。 + :type remote_addr: str + + :param connected_time: 本次推流开始时间。 + :type connected_time: str + + :param video: 视频描述信息。 + :type video: class:`LiveChannelStatVideo ` + + :param audio: 音频描述信息。 + :type audio: class:`LiveChannelStatAudio `""" + + def __init__(self, + status = '', + remote_addr = '', + connected_time = '', + video = None, + audio = None): + self.status = status + self.remote_addr = remote_addr + self.connected_time = connected_time + self.video = video + self.audio = audio + + +class LiveRecord(object): + """直播频道中的推流记录信息 + + :param start_time: 本次推流开始时间。 + :type start_time: str + + :param end_time: 本次推流结束时间。 + :type end_time: str + + :param remote_addr: 推流时客户端的地址。 + :type remote_addr: str""" + + def __init__(self, + start_time = '', + end_time = '', + remote_addr = ''): + self.start_time = start_time + self.end_time = end_time + self.remote_addr = remote_addr + + +class LiveChannelHistory(object): + """直播频道下的推流记录。""" + + def __init__(self): + self.records = [] + + +class CreateLiveChannelResult(RequestResult, LiveChannelInfo): + def __init__(self, resp): + RequestResult.__init__(self, resp) + LiveChannelInfo.__init__(self) + + +class GetLiveChannelResult(RequestResult, LiveChannelInfo): + def __init__(self, resp): + RequestResult.__init__(self, resp) + LiveChannelInfo.__init__(self) + + +class ListLiveChannelResult(RequestResult, LiveChannelList): + def __init__(self, resp): + RequestResult.__init__(self, resp) + LiveChannelList.__init__(self) + + +class GetLiveChannelStatResult(RequestResult, LiveChannelStat): + def __init__(self, resp): + RequestResult.__init__(self, resp) + LiveChannelStat.__init__(self) + +class GetLiveChannelHistoryResult(RequestResult, LiveChannelHistory): + def __init__(self, resp): + RequestResult.__init__(self, resp) + LiveChannelHistory.__init__(self) diff --git a/oss2/xml_utils.py b/oss2/xml_utils.py index fa3afba6..1cf47136 100644 --- a/oss2/xml_utils.py +++ b/oss2/xml_utils.py @@ -20,7 +20,10 @@ MultipartUploadInfo, LifecycleRule, LifecycleExpiration, - CorsRule) + CorsRule, + LiveChannelInfoTarget, + LiveChannelInfo, + LiveRecord) from .compat import urlunquote, to_unicode, to_string from .utils import iso8601_to_unixtime, date_to_iso8601, iso8601_to_date @@ -83,6 +86,8 @@ def _add_node_list(parent, tag, entries): def _add_text_child(parent, tag, text): ElementTree.SubElement(parent, tag).text = to_unicode(text) +def _add_node_child(parent, tag): + return ElementTree.SubElement(parent, tag) def parse_list_objects(result, body): root = ElementTree.fromstring(body) @@ -229,6 +234,106 @@ def parse_get_bucket_websiste(result, body): return result +def parse_create_live_channel(result, body): + root = ElementTree.fromstring(body) + + result.play_url = _find_tag(root, 'PlayUrls/Url') + result.publish_url = _find_tag(root, 'PublishUrls/Url') + + return result + + +def parse_get_live_channel(result, body): + root = ElementTree.fromstring(body) + + result.status = _find_tag(root, 'Status') + result.description = _find_tag(root, 'Description') + + target = LiveChannelInfoTarget() + target.type = _find_tag(root, 'Target/Type') + target.frag_duration = _find_tag(root, 'Target/FragDuration') + target.frag_count = _find_tag(root, 'Target/FragCount') + target.playlist_name = _find_tag(root, 'Target/PlaylistName') + + result.target = target + + return result + + +def parse_list_live_channel(result, body): + root = ElementTree.fromstring(body) + + result.prefix = _find_tag(root, 'Prefix') + result.marker = _find_tag(root, 'Marker') + result.max_keys = _find_int(root, 'MaxKeys') + result.is_truncated = _find_bool(root, 'IsTruncated') + result.next_marker = _find_tag(root, 'NextMarker') + + channels = root.findall('LiveChannel') + for channel in channels: + tmp = LiveChannelInfo() + tmp.id = _find_tag(channel, 'Id') + tmp.description = _find_tag(channel, 'Description') + tmp.status = _find_tag(channel, 'Status') + tmp.modified = _find_tag(channel, 'LastModified') + tmp.play_url = _find_tag(channel, 'PlayUrls/Url') + tmp.publish_url = _find_tag(channel, 'PublishUrls/Url') + + result.channels.append(tmp) + + return result + + +def parse_stat_video(video_node, video): + video.width = _find_int(video_node, 'Width') + video.height = _find_int(video_node, 'Height') + video.frame_rate = _find_int(video_node, 'FrameRate') + video.bandwidth = _find_int(video_node, 'Bandwidth') + video.codec = _find_tag(video_node, 'Codec') + + +def parse_stat_audio(audio_node, audio): + audio.bandwidth = _find_int(audio_node, 'Bandwidth') + audio.sample_rate = _find_int(audio_node, 'SampleRate') + audio.codec = _find(audio_node, 'Codec') + + +def parse_live_channel_stat(result, body): + root = ElementTree.fromstring(body) + + result.status = _find_tag(root, 'Status') + if root.find('RemoteAddr'): + result.remote_addr = _find_tag(root, 'RemoteAddr') + result.connected_time = int(_find_tag(root, 'ConnectedTime')) + + video_node = root.find('Video') + audio_node = root.find('Audio') + + if video_node is not None: + result.video = LiveChannelStatVideo() + parse_stat_video(video_node, result.video) + if audio_node is not None: + result.audio = LiveChannelStatAudio() + parse_stat_audio(audio_node, result.audio) + + return result + + +def parse_live_channel_history(result, body): + root = ElementTree.fromstring(body) + + records = root.findall('LiveRecord') + for record in records: + tmp = LiveRecord() + tmp.start_time = _find_tag(record, 'StartTime') + tmp.end_time = _find_tag(record, 'EndTime') + tmp.remote_addr = _find_tag(record, 'RemoteAddr') + + root.records.append(tmp) + + return result + + def parse_lifecycle_expiration(expiration_node): if expiration_node is None: return None @@ -369,4 +474,18 @@ def to_put_bucket_cors(bucket_cors): if rule.max_age_seconds is not None: _add_text_child(rule_node, 'MaxAgeSeconds', str(rule.max_age_seconds)) - return _node_to_string(root) \ No newline at end of file + return _node_to_string(root) + +def to_create_live_channel(live_channel): + root = ElementTree.Element('LiveChannelConfiguration') + + _add_text_child(root, 'Description', live_channel.description) + _add_text_child(root, 'Status', live_channel.status) + target_node = _add_node_child(root, 'Target') + + _add_text_child(target_node, 'Type', live_channel.target.type) + _add_text_child(target_node, 'FragDuration', str(live_channel.target.frag_duration)) + _add_text_child(target_node, 'FragCount', str(live_channel.target.frag_count)) + _add_text_child(target_node, 'PlaylistName', str(live_channel.target.playlist_name)) + + return _node_to_string(root) diff --git a/tests/test_iterator.py b/tests/test_iterator.py index 1bd4095c..3c4e335e 100644 --- a/tests/test_iterator.py +++ b/tests/test_iterator.py @@ -159,6 +159,30 @@ def test_part_iterator(self): self.bucket.abort_multipart_upload(key, upload_id) + def test_live_channel_iterator(self): + prefix = self.random_key() + channel_id_list = [] + + channel_target = oss2.models.LiveChannelInfoTarget(playlist_name = 'test.m3u8') + channel_info = oss2.models.LiveChannelInfo(target = channel_target) + # 准备频道 + for i in range(20): + channel_id_list.append(prefix + random_string(16)) + self.bucket.create_live_channel(channel_id_list[-1], channel_info) + + # 验证 + live_channel_got = [] + for info in oss2.LiveChannelIterator(self.bucket, prefix, max_keys=4): + live_channel_got.append(info.id) + + result = self.bucket.get_live_channel(info.id) + self.assertEqual(result.description, info.description) + + self.assertEqual(sorted(channel_id_list), live_channel_got) + + for live_channel in channel_id_list: + self.bucket.delete_live_channel(live_channel) + if __name__ == '__main__': - unittest.main() \ No newline at end of file + unittest.main() diff --git a/tests/test_live_channel.py b/tests/test_live_channel.py new file mode 100644 index 00000000..39d259d1 --- /dev/null +++ b/tests/test_live_channel.py @@ -0,0 +1,255 @@ +# -*- coding: utf-8 -*- + +import unittest +import datetime +import time +import os, sys +parentdir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, parentdir) +import oss2 + +from common import * +from oss2.exceptions import * + + +class TestLiveChannel(unittest.TestCase): + def __init__(self, *args, **kwargs): + super(TestLiveChannel, self).__init__(*args, **kwargs) + self.bucket = None + + def setUp(self): + self.bucket = oss2.Bucket(oss2.Auth(OSS_ID, OSS_SECRET), OSS_ENDPOINT, OSS_BUCKET) + self.bucket.create_bucket() + + def _get_play_url(self, bucket_name, channel_id, playlist_name): + return 'http://%s.%s/%s/%s' % (bucket_name, OSS_ENDPOINT, channel_id, playlist_name if playlist_name else 'playlist.m3u8') + + def _get_publish_url(self, bucket_name, channel_id): + return 'rtmp://%s.%s/live/%s' % (bucket_name, OSS_ENDPOINT, channel_id) + + def _get_fixed_number(self, size, n): + nstr = str(n) + if size > len(nstr): + nstr = (size - len(nstr)) * '0' + nstr + return nstr + + def _assert_list_result(self, + result, + marker = '', + prefix = '', + next_marker = '', + max_keys = 0, + is_truncated = False, + return_count = 0): + self.assertEqual(result.prefix, prefix) + self.assertEqual(result.marker, marker) + self.assertEqual(result.next_marker, next_marker) + self.assertEqual(result.max_keys, max_keys) + self.assertEqual(result.is_truncated, is_truncated) + self.assertEqual(len(result.channels), return_count) + + def test_create_live_channel(self): + auth = oss2.Auth(OSS_ID, OSS_SECRET) + bucket_name = random_string(63).lower() + bucket = oss2.Bucket(auth, OSS_ENDPOINT, bucket_name) + bucket.create_bucket(oss2.BUCKET_ACL_PRIVATE) + + channel_id = 'rtmp-channel' + playlist_name = 'test.m3u8' + channel_target = oss2.models.LiveChannelInfoTarget(playlist_name = playlist_name) + channel_info = oss2.models.LiveChannelInfo(target = channel_target) + create_result = bucket.create_live_channel(channel_id, channel_info) + + self.assertEqual(create_result.play_url, + self._get_play_url(bucket_name, channel_id, playlist_name)) + self.assertEqual(create_result.publish_url, + self._get_publish_url(bucket_name, channel_id)) + + delete_result = bucket.delete_live_channel(channel_id) + bucket.delete_bucket() + self.assertRaises(oss2.exceptions.NoSuchBucket, bucket.delete_bucket) + + def test_get_live_channel(self): + auth = oss2.Auth(OSS_ID, OSS_SECRET) + bucket = oss2.Bucket(auth, OSS_ENDPOINT, random_string(63).lower()) + bucket.create_bucket(oss2.BUCKET_ACL_PRIVATE) + + channel_id = 'rtmp-channel' + + self.assertRaises(NoSuchLiveChannel, bucket.get_live_channel, channel_id) + + channel_target = oss2.models.LiveChannelInfoTarget(playlist_name = 'test.m3u8') + channel_info = oss2.models.LiveChannelInfo(target = channel_target) + create_result = bucket.create_live_channel(channel_id, channel_info) + + get_result = bucket.get_live_channel(channel_id) + self.assertEqual(get_result.description, channel_info.description) + self.assertEqual(get_result.status, channel_info.status) + self.assertEqual(get_result.target.type, channel_target.type) + self.assertEqual(get_result.target.frag_duration, str(channel_target.frag_duration)) + self.assertEqual(get_result.target.frag_count, str(channel_target.frag_count)) + self.assertEqual(get_result.target.playlist_name, channel_target.playlist_name) + + bucket.delete_live_channel(channel_id) + bucket.delete_bucket() + self.assertRaises(oss2.exceptions.NoSuchBucket, bucket.delete_bucket) + + def test_list_live_channel(self): + auth = oss2.Auth(OSS_ID, OSS_SECRET) + bucket = oss2.Bucket(auth, OSS_ENDPOINT, random_string(63).lower()) + bucket.create_bucket(oss2.BUCKET_ACL_PRIVATE) + + channel_id = 'rtmp-channel' + self.assertRaises(NoSuchLiveChannel, bucket.get_live_channel, channel_id) + + list_result = bucket.list_live_channel() + self._assert_list_result(list_result, + prefix = '', + marker = '', + next_marker = '', + max_keys = 100, + is_truncated = False, + return_count = 0) + + channel_id_list = [] + prefix1 = random_string(5) + channel_target = oss2.models.LiveChannelInfoTarget(playlist_name = 'test.m3u8') + channel_info = oss2.models.LiveChannelInfo(target = channel_target) + for index in xrange(0, 200): + channel_id_list.append(prefix1 + self._get_fixed_number(10, index)) + bucket.create_live_channel(channel_id_list[index], channel_info) + + list_result = bucket.list_live_channel() + next_marker = prefix1 + self._get_fixed_number(10, 99) + self._assert_list_result(list_result, + prefix = '', + marker = '', + next_marker = next_marker, + max_keys = 100, + is_truncated = True, + return_count = 100) + + prefix2 = random_string(5) + list_result = bucket.list_live_channel(prefix = prefix2) + self._assert_list_result(list_result, + prefix = prefix2, + marker = '', + next_marker = '', + max_keys = 100, + is_truncated = False, + return_count = 0) + + marker = prefix1 + self._get_fixed_number(10, 100) + list_result = bucket.list_live_channel( + prefix = prefix1, + marker = marker) + self._assert_list_result(list_result, + prefix = prefix1, + marker = marker, + next_marker = '', + max_keys = 100, + is_truncated = False, + return_count = 99) + + max_keys = 1000 + list_result = bucket.list_live_channel(max_keys = max_keys) + self._assert_list_result(list_result, + prefix = '', + marker = '', + next_marker = '', + max_keys = max_keys, + is_truncated = False, + return_count = 200) + + for channel_id in channel_id_list: + bucket.delete_live_channel(channel_id) + bucket.delete_bucket() + self.assertRaises(oss2.exceptions.NoSuchBucket, bucket.delete_bucket) + + def test_get_live_channel_stat(self): + auth = oss2.Auth(OSS_ID, OSS_SECRET) + bucket = oss2.Bucket(auth, OSS_ENDPOINT, random_string(63).lower()) + bucket.create_bucket(oss2.BUCKET_ACL_PRIVATE) + + channel_id = 'rtmp-channel' + _playlist_name = 'test.m3u8' + channel_target = oss2.models.LiveChannelInfoTarget(playlist_name = _playlist_name) + channel_info = oss2.models.LiveChannelInfo(target = channel_target) + create_result = bucket.create_live_channel(channel_id, channel_info) + + get_stat_result = bucket.get_live_channel_stat(channel_id) + self.assertEqual(get_stat_result.status, 'Idle') + + bucket.delete_live_channel(channel_id) + bucket.delete_bucket() + self.assertRaises(oss2.exceptions.NoSuchBucket, bucket.delete_bucket) + + def test_put_live_channel_status(self): + auth = oss2.Auth(OSS_ID, OSS_SECRET) + bucket = oss2.Bucket(auth, OSS_ENDPOINT, random_string(63).lower()) + bucket.create_bucket(oss2.BUCKET_ACL_PRIVATE) + + channel_id = 'rtmp-channel' + channel_target = oss2.models.LiveChannelInfoTarget() + channel_info = oss2.models.LiveChannelInfo(target = channel_target) + create_result = bucket.create_live_channel(channel_id, channel_info) + + get_result = bucket.get_live_channel(channel_id) + self.assertEqual(get_result.status, 'enabled') + + bucket.put_live_channel_status(channel_id, 'disabled') + + get_result = bucket.get_live_channel(channel_id) + self.assertEqual(get_result.status, 'disabled') + + bucket.delete_live_channel(channel_id) + bucket.delete_bucket() + self.assertRaises(oss2.exceptions.NoSuchBucket, bucket.delete_bucket) + + def test_get_live_channel_history(self): + auth = oss2.Auth(OSS_ID, OSS_SECRET) + bucket = oss2.Bucket(auth, OSS_ENDPOINT, random_string(63).lower()) + bucket.create_bucket(oss2.BUCKET_ACL_PRIVATE) + + channel_id = 'rtmp-channel' + channel_target = oss2.models.LiveChannelInfoTarget() + channel_info = oss2.models.LiveChannelInfo(target = channel_target) + create_result = bucket.create_live_channel(channel_id, channel_info) + + get_result = bucket.get_live_channel_history(channel_id) + self.assertEqual(len(get_result.records), 0) + + bucket.delete_live_channel(channel_id) + bucket.delete_bucket() + self.assertRaises(oss2.exceptions.NoSuchBucket, bucket.delete_bucket) + + def test_post_vod_playlist(self): + auth = oss2.Auth(OSS_ID, OSS_SECRET) + bucket = oss2.Bucket(auth, OSS_ENDPOINT, random_string(63).lower()) + bucket.create_bucket(oss2.BUCKET_ACL_PRIVATE) + + channel_id = 'rtmp-channel' + channel_target = oss2.models.LiveChannelInfoTarget() + channel_info = oss2.models.LiveChannelInfo(target = channel_target) + create_result = bucket.create_live_channel(channel_id, channel_info) + + # publish rtmp stream here, generate some ts file on oss. + + end_time = int(time.time()) - 60 + start_time = end_time - 3600 + playlist_name = 'vod_playlist.m3u8' + + # throw exception because no ts file been generated. + self.assertRaises(oss2.exceptions.InvalidArgument, + bucket.post_vod_playlist, + channel_id, + playlist_name, + start_time = start_time, + end_time = end_time) + + bucket.delete_live_channel(channel_id) + bucket.delete_bucket() + self.assertRaises(oss2.exceptions.NoSuchBucket, bucket.delete_bucket) + +if __name__ == '__main__': + unittest.main()