Initial commit
commit
74f247223f
|
@ -0,0 +1,3 @@
|
|||
.idea/
|
||||
*.py[cod]
|
||||
bin/
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<addon id="script.module.torrent2http" name="torrent2http" version="0.0.4" provider-name="anteo">
|
||||
<requires>
|
||||
<import addon="xbmc.python" version="2.14.0"/>
|
||||
</requires>
|
||||
<extension point="xbmc.python.module" library="lib"/>
|
||||
<extension point="xbmc.addon.metadata">
|
||||
<platform>all</platform>
|
||||
<language>en</language>
|
||||
<summary lang="en">Downloads torrents and share it over HTTP</summary>
|
||||
<summary lang="ru">Загружает торренты и раздает их по HTTP</summary>
|
||||
<description lang="ru">Обеспечивает последовательную (sequential) загрузку торрентов для потокового онлайн просмотра через HTTP. Основан на библиотеке LibTorrent.</description>
|
||||
<description lang="en">Provides sequential torrent downloading for online streaming video and other media over HTTP.</description>
|
||||
<email>anteo@academ.org</email>
|
||||
</extension>
|
||||
</addon>
|
|
@ -0,0 +1,8 @@
|
|||
[B]Version 0.0.3[/B]
|
||||
+ Detect media types
|
||||
|
||||
[B]Version 0.0.2[/B]
|
||||
+ Added some configurable libtorrent options
|
||||
|
||||
[B]Version 0.0.1[/B]
|
||||
+ Initial release
|
|
@ -0,0 +1,44 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from collections import namedtuple
|
||||
|
||||
|
||||
# noinspection PyClassHasNoInit
|
||||
class State:
|
||||
QUEUED_FOR_CHECKING = 0
|
||||
CHECKING_FILES = 1
|
||||
DOWNLOADING_METADATA = 2
|
||||
DOWNLOADING = 3
|
||||
FINISHED = 4
|
||||
SEEDING = 5
|
||||
ALLOCATING = 6
|
||||
CHECKING_RESUME_DATA = 7
|
||||
|
||||
|
||||
# noinspection PyClassHasNoInit
|
||||
class MediaType:
|
||||
UNKNOWN = None
|
||||
AUDIO = 'audio'
|
||||
VIDEO = 'video'
|
||||
SUBTITLES = 'subtitles'
|
||||
|
||||
|
||||
# noinspection PyClassHasNoInit
|
||||
class Encryption:
|
||||
FORCED = 0
|
||||
ENABLED = 1
|
||||
DISABLED = 2
|
||||
|
||||
|
||||
SessionStatus = namedtuple('SessionStatus', "name, state, state_str, error, progress, download_rate, upload_rate, "
|
||||
"total_download, total_upload, num_peers, num_seeds, total_seeds, "
|
||||
"total_peers")
|
||||
|
||||
FileStatus = namedtuple('FileStatus', "name, save_path, url, size, offset, download, progress, index, media_type")
|
||||
|
||||
PeerInfo = namedtuple('PeerInfo', "ip, flags, source, up_speed, down_speed, total_upload, total_download, "
|
||||
"country, client")
|
||||
|
||||
from engine import Engine
|
||||
from platform import Platform
|
||||
from error import Error
|
|
@ -0,0 +1,368 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib2
|
||||
import logpipe
|
||||
import mimetypes
|
||||
import xbmc
|
||||
|
||||
from error import Error
|
||||
from platform import Platform
|
||||
from . import SessionStatus, FileStatus, PeerInfo, MediaType, Encryption
|
||||
from os.path import dirname
|
||||
|
||||
|
||||
class Engine:
|
||||
SUBTITLES_FORMATS = ['.aqt', '.gsub', '.jss', '.sub', '.ttxt', '.pjs', '.psb', '.rt', '.smi', '.stl',
|
||||
'.ssf', '.srt', '.ssa', '.ass', '.usf', '.idx']
|
||||
|
||||
def _ensure_binary_executable(self, path):
|
||||
st = os.stat(path)
|
||||
if not st.st_mode & stat.S_IEXEC:
|
||||
self._log("%s is not executable, trying to change its mode..." % path)
|
||||
os.chmod(path, st.st_mode | stat.S_IEXEC)
|
||||
st = os.stat(path)
|
||||
if st.st_mode & stat.S_IEXEC:
|
||||
self._log("Succeeded")
|
||||
return True
|
||||
else:
|
||||
self._log("Failed")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _log(self, message):
|
||||
if self.logger:
|
||||
self.logger(message)
|
||||
else:
|
||||
xbmc.log("[torrent2http] %s" % message)
|
||||
|
||||
def _get_binary_path(self, binaries_path):
|
||||
binary = "torrent2http" + (".exe" if self.platform.system == 'windows' else "")
|
||||
binary_dir = os.path.join(binaries_path, "%s_%s" % (self.platform.system, self.platform.arch))
|
||||
binary_path = os.path.join(binary_dir, binary)
|
||||
if not os.path.isfile(binary_path):
|
||||
raise Error("Can't find torrent2http binary for %s" % self.platform,
|
||||
Error.UNKNOWN_PLATFORM, platform=str(self.platform))
|
||||
|
||||
if not self._ensure_binary_executable(binary_path):
|
||||
if self.platform.system == "android":
|
||||
self._log("Trying to copy torrent2http to ext4, since the sdcard is noexec...")
|
||||
xbmc_home = os.environ.get('XBMC_HOME') or os.environ.get('KODI_HOME')
|
||||
if not xbmc_home:
|
||||
raise Error("Suppose we are running XBMC, but environment variable "
|
||||
"XBMC_HOME or KODI_HOME is not found", Error.XBMC_HOME_NOT_DEFINED)
|
||||
base_xbmc_path = dirname(dirname(dirname(xbmc_home)))
|
||||
android_binary_dir = os.path.join(base_xbmc_path, "files")
|
||||
if not os.path.exists(android_binary_dir):
|
||||
os.makedirs(android_binary_dir)
|
||||
android_binary_path = os.path.join(android_binary_dir, binary)
|
||||
if not os.path.exists(android_binary_path) or \
|
||||
int(os.path.getmtime(android_binary_path)) < int(os.path.getmtime(binary_path)):
|
||||
import shutil
|
||||
shutil.copy2(binary_path, android_binary_path)
|
||||
if not self._ensure_binary_executable(android_binary_path):
|
||||
raise Error("Can't make %s executable" % android_binary_path, Error.NOEXEC_FILESYSTEM)
|
||||
binary_path = android_binary_path
|
||||
else:
|
||||
raise Error("Can't make %s executable, ensure it's placed on exec partition and "
|
||||
"partition is in read/write mode" % binary_path, Error.NOEXEC_FILESYSTEM)
|
||||
self._log("Selected %s as torrent2http binary" % binary_path)
|
||||
return binary_path
|
||||
|
||||
@staticmethod
|
||||
def _can_bind(host, port):
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.bind((host, port))
|
||||
s.close()
|
||||
except socket.error:
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _find_free_port(host):
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.bind((host, 0))
|
||||
port = s.getsockname()[1]
|
||||
s.close()
|
||||
except socket.error:
|
||||
return False
|
||||
return port
|
||||
|
||||
def __init__(self, uri=None, binaries_path=None, platform=None, download_path=".",
|
||||
bind_host='127.0.0.1', bind_port=5001, connections_limit=None, download_kbps=None, upload_kbps=None,
|
||||
enable_dht=True, enable_lsd=True, enable_natpmp=True, enable_upnp=True, enable_scrape=False,
|
||||
log_stats=False, encryption=Encryption.ENABLED, keep_complete=False, keep_incomplete=False,
|
||||
keep_files=False, log_files_progress=False, log_overall_progress=False, log_pieces_progress=False,
|
||||
listen_port=6881, use_random_port=False, max_idle_timeout=None, no_sparse=False, resume_file=None,
|
||||
user_agent=None, startup_timeout=5, state_file=None, enable_utp=True, enable_tcp=True,
|
||||
debug_alerts=False, logger=None, torrent_connect_boost=50, connection_speed=50,
|
||||
peer_connect_timeout=15, request_timeout=20, min_reconnect_time=60, max_failcount=3,
|
||||
dht_routers=None, trackers=None):
|
||||
self.dht_routers = dht_routers or []
|
||||
self.trackers = trackers or []
|
||||
self.max_failcount = max_failcount
|
||||
self.min_reconnect_time = min_reconnect_time
|
||||
self.request_timeout = request_timeout
|
||||
self.peer_connect_timeout = peer_connect_timeout
|
||||
self.connection_speed = connection_speed
|
||||
self.torrent_connect_boost = torrent_connect_boost
|
||||
self.platform = platform
|
||||
self.bind_host = bind_host
|
||||
self.bind_port = bind_port
|
||||
self.binaries_path = binaries_path or os.path.join(dirname(dirname(dirname(os.path.abspath(__file__)))), 'bin')
|
||||
self.download_path = download_path
|
||||
self.connections_limit = connections_limit
|
||||
self.download_kbps = download_kbps
|
||||
self.upload_kbps = upload_kbps
|
||||
self.enable_dht = enable_dht
|
||||
self.enable_lsd = enable_lsd
|
||||
self.enable_natpmp = enable_natpmp
|
||||
self.enable_upnp = enable_upnp
|
||||
self.enable_scrape = enable_scrape
|
||||
self.log_stats = log_stats
|
||||
self.encryption = encryption
|
||||
self.keep_complete = keep_complete
|
||||
self.keep_incomplete = keep_incomplete
|
||||
self.keep_files = keep_files
|
||||
self.log_files_progress = log_files_progress
|
||||
self.log_overall_progress = log_overall_progress
|
||||
self.log_pieces_progress = log_pieces_progress
|
||||
self.listen_port = listen_port
|
||||
self.use_random_port = use_random_port
|
||||
self.max_idle_timeout = max_idle_timeout
|
||||
self.no_sparse = no_sparse
|
||||
self.resume_file = resume_file
|
||||
self.user_agent = user_agent
|
||||
self.startup_timeout = startup_timeout
|
||||
self.state_file = state_file
|
||||
self.wait_on_close_timeout = None
|
||||
self.enable_utp = enable_utp
|
||||
self.enable_tcp = enable_tcp
|
||||
self.debug_alerts = debug_alerts
|
||||
self.logger = logger
|
||||
self.uri = uri
|
||||
self.logpipe = None
|
||||
self.process = None
|
||||
|
||||
@staticmethod
|
||||
def _validate_save_path(path):
|
||||
if "://" in path:
|
||||
if sys.platform.startswith('win') and path.lower().startswith("smb://"):
|
||||
path = path.replace("smb:", "").replace("/", "\\")
|
||||
else:
|
||||
raise Error("Downloading to an unmounted network share is not supported", Error.INVALID_DOWNLOAD_PATH)
|
||||
if not os.path.isdir(path):
|
||||
raise Error("Download path doesn't exist (%s)" % path, Error.INVALID_DOWNLOAD_PATH)
|
||||
return path
|
||||
|
||||
def start(self, start_index=None):
|
||||
self.platform = self.platform or Platform()
|
||||
binary_path = self._get_binary_path(self.binaries_path)
|
||||
download_path = self._validate_save_path(self.download_path)
|
||||
if not self._can_bind(self.bind_host, self.bind_port):
|
||||
port = self._find_free_port(self.bind_host)
|
||||
if port is False:
|
||||
raise Error("Can't find port to bind torrent2http", Error.BIND_ERROR)
|
||||
self._log("Can't bind to %s:%s, so we found another port: %d" % (self.bind_host, self.bind_port, port))
|
||||
self.bind_port = port
|
||||
|
||||
kwargs = {
|
||||
'--bind': "%s:%s" % (self.bind_host, self.bind_port),
|
||||
'--uri': self.uri,
|
||||
'--file-index': start_index,
|
||||
'--dl-path': download_path,
|
||||
'--connections-limit': self.connections_limit,
|
||||
'--dl-rate': self.download_kbps,
|
||||
'--ul-rate': self.upload_kbps,
|
||||
'--enable-dht': self.enable_dht,
|
||||
'--enable-lsd': self.enable_lsd,
|
||||
'--enable-natpmp': self.enable_natpmp,
|
||||
'--enable-upnp': self.enable_upnp,
|
||||
'--enable-scrape': self.enable_scrape,
|
||||
'--encryption': self.encryption,
|
||||
'--show-stats': self.log_stats,
|
||||
'--files-progress': self.log_files_progress,
|
||||
'--overall-progress': self.log_overall_progress,
|
||||
'--pieces-progress': self.log_pieces_progress,
|
||||
'--listen-port': self.listen_port,
|
||||
'--random-port': self.use_random_port,
|
||||
'--keep-complete': self.keep_complete,
|
||||
'--keep-incomplete': self.keep_incomplete,
|
||||
'--keep-files': self.keep_files,
|
||||
'--max-idle': self.max_idle_timeout,
|
||||
'--no-sparse': self.no_sparse,
|
||||
'--resume-file': self.resume_file,
|
||||
'--user-agent': self.user_agent,
|
||||
'--state-file': self.state_file,
|
||||
'--enable-utp': self.enable_utp,
|
||||
'--enable-tcp': self.enable_tcp,
|
||||
'--debug-alerts': self.debug_alerts,
|
||||
'--torrent-connect-boost': self.torrent_connect_boost,
|
||||
'--connection-speed': self.connection_speed,
|
||||
'--peer-connect-timeout': self.peer_connect_timeout,
|
||||
'--request-timeout': self.request_timeout,
|
||||
'--min-reconnect-time': self.min_reconnect_time,
|
||||
'--max-failcount': self.max_failcount,
|
||||
'--dht-routers': ",".join(self.dht_routers),
|
||||
'--trackers': ",".join(self.trackers),
|
||||
}
|
||||
|
||||
args = [binary_path]
|
||||
for k, v in kwargs.iteritems():
|
||||
if v is not None:
|
||||
if isinstance(v, bool):
|
||||
if v:
|
||||
args.append(k)
|
||||
else:
|
||||
args.append("%s=false" % k)
|
||||
else:
|
||||
args.append(k)
|
||||
if isinstance(v, str):
|
||||
v = v.decode('utf-8')
|
||||
if isinstance(v, unicode):
|
||||
v = v.encode(sys.getfilesystemencoding() or 'utf-8')
|
||||
else:
|
||||
v = str(v)
|
||||
args.append(v)
|
||||
|
||||
self._log("Invoking %s" % " ".join(args))
|
||||
startupinfo = None
|
||||
if self.platform.system == "windows":
|
||||
startupinfo = subprocess.STARTUPINFO()
|
||||
startupinfo.dwFlags |= 1
|
||||
startupinfo.wShowWindow = 0
|
||||
|
||||
self.logpipe = logpipe.LogPipe(self._log)
|
||||
try:
|
||||
self.process = subprocess.Popen(args, stderr=self.logpipe, stdout=self.logpipe, startupinfo=startupinfo)
|
||||
except OSError, e:
|
||||
raise Error("Can't start torrent2http: %r" % e, Error.POPEN_ERROR)
|
||||
|
||||
start = time.time()
|
||||
initialized = False
|
||||
while (time.time() - start) < self.startup_timeout:
|
||||
time.sleep(0.1)
|
||||
if not self.is_alive():
|
||||
raise Error("Can't start torrent2http, see log for details", Error.PROCESS_ERROR)
|
||||
try:
|
||||
self.status(1)
|
||||
initialized = True
|
||||
break
|
||||
except Error:
|
||||
pass
|
||||
|
||||
if not initialized:
|
||||
raise Error("Can't start torrent2http, time is out", Error.TIMEOUT)
|
||||
self._log("torrent2http successfully started.")
|
||||
|
||||
def check_torrent_error(self, status=None):
|
||||
if not status:
|
||||
status = self.status()
|
||||
if status.error:
|
||||
raise Error("Torrent error: %s" % status.error, Error.TORRENT_ERROR, reason=status.error)
|
||||
|
||||
def status(self, timeout=10):
|
||||
status = self._decode(self._request('status', timeout))
|
||||
status = SessionStatus(**status)
|
||||
return status
|
||||
|
||||
def _detect_media_type(self, name):
|
||||
ext = os.path.splitext(name)[1]
|
||||
if ext in self.SUBTITLES_FORMATS:
|
||||
return MediaType.SUBTITLES
|
||||
else:
|
||||
mime_type = mimetypes.guess_type(name)[0]
|
||||
if not mime_type:
|
||||
return MediaType.UNKNOWN
|
||||
mime_type = mime_type.split("/")[0]
|
||||
if mime_type == 'audio':
|
||||
return MediaType.AUDIO
|
||||
elif mime_type == 'video':
|
||||
return MediaType.VIDEO
|
||||
else:
|
||||
return MediaType.UNKNOWN
|
||||
|
||||
def list(self, media_types=None, timeout=10):
|
||||
files = self._decode(self._request('ls', timeout))['files']
|
||||
if files:
|
||||
res = [FileStatus(index=index, media_type=self._detect_media_type(f['name']), **f)
|
||||
for index, f in enumerate(files)]
|
||||
if media_types is not None:
|
||||
res = filter(lambda fs: fs.media_type in media_types, res)
|
||||
return res
|
||||
|
||||
def file_status(self, file_index, timeout=10):
|
||||
res = self.list(timeout=timeout)
|
||||
if res:
|
||||
try:
|
||||
return next((f for f in res if f.index == file_index))
|
||||
except StopIteration:
|
||||
raise Error("Requested file index (%d) is invalid" % file_index, Error.INVALID_FILE_INDEX,
|
||||
file_index=file_index)
|
||||
|
||||
def peers(self, timeout=10):
|
||||
peers = self._decode(self._request('peers', timeout))['peers']
|
||||
if peers:
|
||||
return [PeerInfo(**p) for p in peers]
|
||||
|
||||
def is_alive(self):
|
||||
return self.process and self.process.poll() is None
|
||||
|
||||
@staticmethod
|
||||
def _decode(response):
|
||||
try:
|
||||
return json.loads(response)
|
||||
except (KeyError, ValueError), e:
|
||||
raise Error("Can't decode response from torrent2http: %r" % e, Error.REQUEST_ERROR)
|
||||
|
||||
def _request(self, cmd, timeout=None):
|
||||
if not self.is_alive():
|
||||
raise Error("torrent2http is not started", Error.REQUEST_ERROR)
|
||||
try:
|
||||
url = "http://%s:%s/%s" % (self.bind_host, self.bind_port, cmd)
|
||||
kwargs = {}
|
||||
if timeout is not None:
|
||||
kwargs['timeout'] = timeout
|
||||
return urllib2.urlopen(url, **kwargs).read()
|
||||
except urllib2.URLError as e:
|
||||
if isinstance(e.reason, socket.timeout):
|
||||
raise Error("Timeout occurred while sending command '%s' to torrent2http" % cmd, Error.TIMEOUT)
|
||||
else:
|
||||
raise Error("Can't send command '%s' to torrent2http: %r" % (cmd, e), Error.REQUEST_ERROR)
|
||||
except socket.error as e:
|
||||
reason = e[1] if isinstance(e, tuple) else e
|
||||
raise Error("Can't read from torrent2http: %s" % reason, Error.REQUEST_ERROR)
|
||||
|
||||
def wait_on_close(self, wait_timeout=10):
|
||||
self.wait_on_close_timeout = wait_timeout
|
||||
|
||||
def close(self):
|
||||
if self.logpipe and self.wait_on_close_timeout is None:
|
||||
self.logpipe.close()
|
||||
if self.is_alive():
|
||||
self._log("Shutting down torrent2http...")
|
||||
self._request('shutdown')
|
||||
finished = False
|
||||
if self.wait_on_close_timeout is not None:
|
||||
start = time.time()
|
||||
os.close(self.logpipe.write_fd)
|
||||
while (time.time() - start) < self.wait_on_close_timeout:
|
||||
time.sleep(0.5)
|
||||
if not self.is_alive():
|
||||
finished = True
|
||||
break
|
||||
if not finished:
|
||||
self._log("Timeout occurred while shutting down torrent2http, killing it")
|
||||
self.process.kill()
|
||||
else:
|
||||
self._log("torrent2http successfully shut down.")
|
||||
self.wait_on_close_timeout = None
|
||||
self.logpipe = None
|
||||
self.process = None
|
|
@ -0,0 +1,21 @@
|
|||
|
||||
class Error(Exception):
|
||||
TORRENT_ERROR = 12
|
||||
UNKNOWN_PLATFORM = 1
|
||||
XBMC_HOME_NOT_DEFINED = 2
|
||||
NOEXEC_FILESYSTEM = 3
|
||||
REQUEST_ERROR = 5
|
||||
INVALID_DOWNLOAD_PATH = 6
|
||||
BIND_ERROR = 7
|
||||
POPEN_ERROR = 8
|
||||
PROCESS_ERROR = 9
|
||||
TIMEOUT = 10
|
||||
INVALID_FILE_INDEX = 11
|
||||
|
||||
def __init__(self, message, code=0, **kwargs):
|
||||
self.message = message
|
||||
self.code = code
|
||||
self.kwargs = kwargs
|
||||
|
||||
def __str__(self):
|
||||
return self.message
|
|
@ -0,0 +1,33 @@
|
|||
import os
|
||||
import threading
|
||||
import re
|
||||
|
||||
|
||||
class LogPipe(threading.Thread):
|
||||
|
||||
def __init__(self, logger):
|
||||
threading.Thread.__init__(self)
|
||||
self.daemon = False
|
||||
self.logger = logger
|
||||
self.read_fd, self.write_fd = os.pipe()
|
||||
self.stop = threading.Event()
|
||||
self.start()
|
||||
|
||||
def fileno(self):
|
||||
return self.write_fd
|
||||
|
||||
def run(self):
|
||||
self.logger("Logging thread started.")
|
||||
with os.fdopen(self.read_fd) as f:
|
||||
for line in iter(f.readline, ""):
|
||||
line = re.sub(r'^\d+/\d+/\d+ \d+:\d+:\d+ ', '', line)
|
||||
self.logger(line.strip())
|
||||
if self.stop.is_set():
|
||||
break
|
||||
self.logger("Logging thread finished.")
|
||||
|
||||
def close(self):
|
||||
self.stop.set()
|
||||
f = os.fdopen(self.write_fd, "w")
|
||||
f.write("Stopping logging thread...\n")
|
||||
f.close()
|
|
@ -0,0 +1,613 @@
|
|||
"""Guess the MIME type of a file.
|
||||
|
||||
This module defines two useful functions:
|
||||
|
||||
guess_type(url, strict=1) -- guess the MIME type and encoding of a URL.
|
||||
|
||||
guess_extension(type, strict=1) -- guess the extension for a given MIME type.
|
||||
|
||||
It also contains the following, for tuning the behavior:
|
||||
|
||||
Data:
|
||||
|
||||
knownfiles -- list of files to parse
|
||||
inited -- flag set when init() has been called
|
||||
suffix_map -- dictionary mapping suffixes to suffixes
|
||||
encodings_map -- dictionary mapping suffixes to encodings
|
||||
types_map -- dictionary mapping suffixes to types
|
||||
|
||||
Functions:
|
||||
|
||||
init([files]) -- parse a list of files, default knownfiles (on Windows, the
|
||||
default values are taken from the registry)
|
||||
read_mime_types(file) -- parse one file, return a dictionary or None
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import posixpath
|
||||
import urllib
|
||||
try:
|
||||
import _winreg
|
||||
except ImportError:
|
||||
_winreg = None
|
||||
|
||||
__all__ = [
|
||||
"guess_type","guess_extension","guess_all_extensions",
|
||||
"add_type","read_mime_types","init"
|
||||
]
|
||||
|
||||
knownfiles = [
|
||||
"/etc/mime.types",
|
||||
"/etc/httpd/mime.types", # Mac OS X
|
||||
"/etc/httpd/conf/mime.types", # Apache
|
||||
"/etc/apache/mime.types", # Apache 1
|
||||
"/etc/apache2/mime.types", # Apache 2
|
||||
"/usr/local/etc/httpd/conf/mime.types",
|
||||
"/usr/local/lib/netscape/mime.types",
|
||||
"/usr/local/etc/httpd/conf/mime.types", # Apache 1.2
|
||||
"/usr/local/etc/mime.types", # Apache 1.3
|
||||
]
|
||||
|
||||
inited = False
|
||||
_db = None
|
||||
|
||||
|
||||
class MimeTypes:
|
||||
"""MIME-types datastore.
|
||||
|
||||
This datastore can handle information from mime.types-style files
|
||||
and supports basic determination of MIME type from a filename or
|
||||
URL, and can guess a reasonable extension given a MIME type.
|
||||
"""
|
||||
|
||||
def __init__(self, filenames=(), strict=True):
|
||||
if not inited:
|
||||
init()
|
||||
self.encodings_map = encodings_map.copy()
|
||||
self.suffix_map = suffix_map.copy()
|
||||
self.types_map = ({}, {}) # dict for (non-strict, strict)
|
||||
self.types_map_inv = ({}, {})
|
||||
for (ext, type) in types_map.items():
|
||||
self.add_type(type, ext, True)
|
||||
for (ext, type) in common_types.items():
|
||||
self.add_type(type, ext, False)
|
||||
for name in filenames:
|
||||
self.read(name, strict)
|
||||
|
||||
def add_type(self, type, ext, strict=True):
|
||||
"""Add a mapping between a type and an extension.
|
||||
|
||||
When the extension is already known, the new
|
||||
type will replace the old one. When the type
|
||||
is already known the extension will be added
|
||||
to the list of known extensions.
|
||||
|
||||
If strict is true, information will be added to
|
||||
list of standard types, else to the list of non-standard
|
||||
types.
|
||||
"""
|
||||
self.types_map[strict][ext] = type
|
||||
exts = self.types_map_inv[strict].setdefault(type, [])
|
||||
if ext not in exts:
|
||||
exts.append(ext)
|
||||
|
||||
def guess_type(self, url, strict=True):
|
||||
"""Guess the type of a file based on its URL.
|
||||
|
||||
Return value is a tuple (type, encoding) where type is None if
|
||||
the type can't be guessed (no or unknown suffix) or a string
|
||||
of the form type/subtype, usable for a MIME Content-type
|
||||
header; and encoding is None for no encoding or the name of
|
||||
the program used to encode (e.g. compress or gzip). The
|
||||
mappings are table driven. Encoding suffixes are case
|
||||
sensitive; type suffixes are first tried case sensitive, then
|
||||
case insensitive.
|
||||
|
||||
The suffixes .tgz, .taz and .tz (case sensitive!) are all
|
||||
mapped to '.tar.gz'. (This is table-driven too, using the
|
||||
dictionary suffix_map.)
|
||||
|
||||
Optional `strict' argument when False adds a bunch of commonly found,
|
||||
but non-standard types.
|
||||
"""
|
||||
scheme, url = urllib.splittype(url)
|
||||
if scheme == 'data':
|
||||
# syntax of data URLs:
|
||||
# dataurl := "data:" [ mediatype ] [ ";base64" ] "," data
|
||||
# mediatype := [ type "/" subtype ] *( ";" parameter )
|
||||
# data := *urlchar
|
||||
# parameter := attribute "=" value
|
||||
# type/subtype defaults to "text/plain"
|
||||
comma = url.find(',')
|
||||
if comma < 0:
|
||||
# bad data URL
|
||||
return None, None
|
||||
semi = url.find(';', 0, comma)
|
||||
if semi >= 0:
|
||||
type = url[:semi]
|
||||
else:
|
||||
type = url[:comma]
|
||||
if '=' in type or '/' not in type:
|
||||
type = 'text/plain'
|
||||
return type, None # never compressed, so encoding is None
|
||||
base, ext = posixpath.splitext(url)
|
||||
while ext in self.suffix_map:
|
||||
base, ext = posixpath.splitext(base + self.suffix_map[ext])
|
||||
if ext in self.encodings_map:
|
||||
encoding = self.encodings_map[ext]
|
||||
base, ext = posixpath.splitext(base)
|
||||
else:
|
||||
encoding = None
|
||||
types_map = self.types_map[True]
|
||||
if ext in types_map:
|
||||
return types_map[ext], encoding
|
||||
elif ext.lower() in types_map:
|
||||
return types_map[ext.lower()], encoding
|
||||
elif strict:
|
||||
return None, encoding
|
||||
types_map = self.types_map[False]
|
||||
if ext in types_map:
|
||||
return types_map[ext], encoding
|
||||
elif ext.lower() in types_map:
|
||||
return types_map[ext.lower()], encoding
|
||||
else:
|
||||
return None, encoding
|
||||
|
||||
def guess_all_extensions(self, type, strict=True):
|
||||
"""Guess the extensions for a file based on its MIME type.
|
||||
|
||||
Return value is a list of strings giving the possible filename
|
||||
extensions, including the leading dot ('.'). The extension is not
|
||||
guaranteed to have been associated with any particular data stream,
|
||||
but would be mapped to the MIME type `type' by guess_type().
|
||||
|
||||
Optional `strict' argument when false adds a bunch of commonly found,
|
||||
but non-standard types.
|
||||
"""
|
||||
type = type.lower()
|
||||
extensions = self.types_map_inv[True].get(type, [])
|
||||
if not strict:
|
||||
for ext in self.types_map_inv[False].get(type, []):
|
||||
if ext not in extensions:
|
||||
extensions.append(ext)
|
||||
return extensions
|
||||
|
||||
def guess_extension(self, type, strict=True):
|
||||
"""Guess the extension for a file based on its MIME type.
|
||||
|
||||
Return value is a string giving a filename extension,
|
||||
including the leading dot ('.'). The extension is not
|
||||
guaranteed to have been associated with any particular data
|
||||
stream, but would be mapped to the MIME type `type' by
|
||||
guess_type(). If no extension can be guessed for `type', None
|
||||
is returned.
|
||||
|
||||
Optional `strict' argument when false adds a bunch of commonly found,
|
||||
but non-standard types.
|
||||
"""
|
||||
extensions = self.guess_all_extensions(type, strict)
|
||||
if not extensions:
|
||||
return None
|
||||
return extensions[0]
|
||||
|
||||
def read(self, filename, strict=True):
|
||||
"""
|
||||
Read a single mime.types-format file, specified by pathname.
|
||||
|
||||
If strict is true, information will be added to
|
||||
list of standard types, else to the list of non-standard
|
||||
types.
|
||||
"""
|
||||
with open(filename) as fp:
|
||||
self.readfp(fp, strict)
|
||||
|
||||
def readfp(self, fp, strict=True):
|
||||
"""
|
||||
Read a single mime.types-format file.
|
||||
|
||||
If strict is true, information will be added to
|
||||
list of standard types, else to the list of non-standard
|
||||
types.
|
||||
"""
|
||||
while 1:
|
||||
line = fp.readline()
|
||||
if not line:
|
||||
break
|
||||
words = line.split()
|
||||
for i in range(len(words)):
|
||||
if words[i][0] == '#':
|
||||
del words[i:]
|
||||
break
|
||||
if not words:
|
||||
continue
|
||||
type, suffixes = words[0], words[1:]
|
||||
for suff in suffixes:
|
||||
self.add_type(type, '.' + suff, strict)
|
||||
|
||||
def read_windows_registry(self, strict=True):
|
||||
"""
|
||||
Load the MIME types database from Windows registry.
|
||||
|
||||
If strict is true, information will be added to
|
||||
list of standard types, else to the list of non-standard
|
||||
types.
|
||||
"""
|
||||
|
||||
# Windows only
|
||||
if not _winreg:
|
||||
return
|
||||
|
||||
def enum_types(mimedb):
|
||||
i = 0
|
||||
while True:
|
||||
try:
|
||||
ctype = _winreg.EnumKey(mimedb, i)
|
||||
except EnvironmentError:
|
||||
break
|
||||
try:
|
||||
ctype = ctype.encode(default_encoding) # omit in 3.x!
|
||||
except UnicodeEncodeError:
|
||||
pass
|
||||
else:
|
||||
yield ctype
|
||||
i += 1
|
||||
|
||||
default_encoding = sys.getdefaultencoding()
|
||||
with _winreg.OpenKey(_winreg.HKEY_CLASSES_ROOT,
|
||||
r'MIME\Database\Content Type') as mimedb:
|
||||
for ctype in enum_types(mimedb):
|
||||
try:
|
||||
with _winreg.OpenKey(mimedb, ctype) as key:
|
||||
suffix, datatype = _winreg.QueryValueEx(key,
|
||||
'Extension')
|
||||
except EnvironmentError:
|
||||
continue
|
||||
if datatype != _winreg.REG_SZ:
|
||||
continue
|
||||
try:
|
||||
suffix = suffix.encode(default_encoding) # omit in 3.x!
|
||||
except UnicodeEncodeError:
|
||||
continue
|
||||
self.add_type(ctype, suffix, strict)
|
||||
|
||||
|
||||
def guess_type(url, strict=True):
|
||||
"""Guess the type of a file based on its URL.
|
||||
|
||||
Return value is a tuple (type, encoding) where type is None if the
|
||||
type can't be guessed (no or unknown suffix) or a string of the
|
||||
form type/subtype, usable for a MIME Content-type header; and
|
||||
encoding is None for no encoding or the name of the program used
|
||||
to encode (e.g. compress or gzip). The mappings are table
|
||||
driven. Encoding suffixes are case sensitive; type suffixes are
|
||||
first tried case sensitive, then case insensitive.
|
||||
|
||||
The suffixes .tgz, .taz and .tz (case sensitive!) are all mapped
|
||||
to ".tar.gz". (This is table-driven too, using the dictionary
|
||||
suffix_map).
|
||||
|
||||
Optional `strict' argument when false adds a bunch of commonly found, but
|
||||
non-standard types.
|
||||
"""
|
||||
if _db is None:
|
||||
init()
|
||||
return _db.guess_type(url, strict)
|
||||
|
||||
|
||||
def guess_all_extensions(type, strict=True):
|
||||
"""Guess the extensions for a file based on its MIME type.
|
||||
|
||||
Return value is a list of strings giving the possible filename
|
||||
extensions, including the leading dot ('.'). The extension is not
|
||||
guaranteed to have been associated with any particular data
|
||||
stream, but would be mapped to the MIME type `type' by
|
||||
guess_type(). If no extension can be guessed for `type', None
|
||||
is returned.
|
||||
|
||||
Optional `strict' argument when false adds a bunch of commonly found,
|
||||
but non-standard types.
|
||||
"""
|
||||
if _db is None:
|
||||
init()
|
||||
return _db.guess_all_extensions(type, strict)
|
||||
|
||||
def guess_extension(type, strict=True):
|
||||
"""Guess the extension for a file based on its MIME type.
|
||||
|
||||
Return value is a string giving a filename extension, including the
|
||||
leading dot ('.'). The extension is not guaranteed to have been
|
||||
associated with any particular data stream, but would be mapped to the
|
||||
MIME type `type' by guess_type(). If no extension can be guessed for
|
||||
`type', None is returned.
|
||||
|
||||
Optional `strict' argument when false adds a bunch of commonly found,
|
||||
but non-standard types.
|
||||
"""
|
||||
if _db is None:
|
||||
init()
|
||||
return _db.guess_extension(type, strict)
|
||||
|
||||
def add_type(type, ext, strict=True):
|
||||
"""Add a mapping between a type and an extension.
|
||||
|
||||
When the extension is already known, the new
|
||||
type will replace the old one. When the type
|
||||
is already known the extension will be added
|
||||
to the list of known extensions.
|
||||
|
||||
If strict is true, information will be added to
|
||||
list of standard types, else to the list of non-standard
|
||||
types.
|
||||
"""
|
||||
if _db is None:
|
||||
init()
|
||||
return _db.add_type(type, ext, strict)
|
||||
|
||||
|
||||
def init(files=None):
|
||||
global suffix_map, types_map, encodings_map, common_types
|
||||
global inited, _db
|
||||
inited = True # so that MimeTypes.__init__() doesn't call us again
|
||||
db = MimeTypes()
|
||||
if files is None:
|
||||
if _winreg:
|
||||
db.read_windows_registry()
|
||||
files = knownfiles
|
||||
for file in files:
|
||||
if os.path.isfile(file):
|
||||
db.read(file)
|
||||
encodings_map = db.encodings_map
|
||||
suffix_map = db.suffix_map
|
||||
types_map = db.types_map[True]
|
||||
common_types = db.types_map[False]
|
||||
# Make the DB a global variable now that it is fully initialized
|
||||
_db = db
|
||||
|
||||
|
||||
def read_mime_types(file):
|
||||
try:
|
||||
f = open(file)
|
||||
except IOError:
|
||||
return None
|
||||
db = MimeTypes()
|
||||
db.readfp(f, True)
|
||||
return db.types_map[True]
|
||||
|
||||
|
||||
def _default_mime_types():
|
||||
global suffix_map
|
||||
global encodings_map
|
||||
global types_map
|
||||
global common_types
|
||||
|
||||
suffix_map = {
|
||||
'.tgz': '.tar.gz',
|
||||
'.taz': '.tar.gz',
|
||||
'.tz': '.tar.gz',
|
||||
'.tbz2': '.tar.bz2',
|
||||
}
|
||||
|
||||
encodings_map = {
|
||||
'.gz': 'gzip',
|
||||
'.Z': 'compress',
|
||||
'.bz2': 'bzip2',
|
||||
}
|
||||
|
||||
# Before adding new types, make sure they are either registered with IANA,
|
||||
# at http://www.isi.edu/in-notes/iana/assignments/media-types
|
||||
# or extensions, i.e. using the x- prefix
|
||||
|
||||
# If you add to these, please keep them sorted!
|
||||
types_map = {
|
||||
'.3gp' : 'video/3gp',
|
||||
'.a' : 'application/octet-stream',
|
||||
'.ai' : 'application/postscript',
|
||||
'.aif' : 'audio/x-aiff',
|
||||
'.aifc' : 'audio/x-aiff',
|
||||
'.aiff' : 'audio/x-aiff',
|
||||
'.asf' : 'video/x-ms-asf',
|
||||
'.asx' : 'video/x-ms-asf',
|
||||
'.au' : 'audio/basic',
|
||||
'.avi' : 'video/x-msvideo',
|
||||
'.axv' : 'video/annodex',
|
||||
'.bat' : 'text/plain',
|
||||
'.bcpio' : 'application/x-bcpio',
|
||||
'.bin' : 'application/octet-stream',
|
||||
'.bmp' : 'image/x-ms-bmp',
|
||||
'.c' : 'text/plain',
|
||||
# Duplicates :(
|
||||
'.cdf' : 'application/x-cdf',
|
||||
'.cdf' : 'application/x-netcdf',
|
||||
'.cpio' : 'application/x-cpio',
|
||||
'.csh' : 'application/x-csh',
|
||||
'.css' : 'text/css',
|
||||
'.dif' : 'video/dv',
|
||||
'.dl' : 'video/dl',
|
||||
'.dll' : 'application/octet-stream',
|
||||
'.dv' : 'video/dv',
|
||||
'.doc' : 'application/msword',
|
||||
'.dot' : 'application/msword',
|
||||
'.dvi' : 'application/x-dvi',
|
||||
'.eml' : 'message/rfc822',
|
||||
'.eps' : 'application/postscript',
|
||||
'.etx' : 'text/x-setext',
|
||||
'.exe' : 'application/octet-stream',
|
||||
'.fli' : 'video/fli',
|
||||
'.flv' : 'video/x-flv',
|
||||
'.gif' : 'image/gif',
|
||||
'.gl' : 'video/gl',
|
||||
'.gtar' : 'application/x-gtar',
|
||||
'.h' : 'text/plain',
|
||||
'.hdf' : 'application/x-hdf',
|
||||
'.htm' : 'text/html',
|
||||
'.html' : 'text/html',
|
||||
'.ief' : 'image/ief',
|
||||
'.jpe' : 'image/jpeg',
|
||||
'.jpeg' : 'image/jpeg',
|
||||
'.jpg' : 'image/jpeg',
|
||||
'.js' : 'application/x-javascript',
|
||||
'.ksh' : 'text/plain',
|
||||
'.latex' : 'application/x-latex',
|
||||
'.lsf' : 'video/x-la-lsf',
|
||||
'.lsx' : 'video/x-la-lsf',
|
||||
'.m1v' : 'video/mpeg',
|
||||
'.man' : 'application/x-troff-man',
|
||||
'.me' : 'application/x-troff-me',
|
||||
'.mht' : 'message/rfc822',
|
||||
'.mhtml' : 'message/rfc822',
|
||||
'.mif' : 'application/x-mif',
|
||||
'.mng' : 'video/x-mng',
|
||||
'.movie' : 'video/x-sgi-movie',
|
||||
'.mp2' : 'audio/mpeg',
|
||||
'.mp3' : 'audio/mpeg',
|
||||
'.mp4' : 'video/mp4',
|
||||
'.mpa' : 'video/mpeg',
|
||||
'.mpe' : 'video/mpeg',
|
||||
'.mpeg' : 'video/mpeg',
|
||||
'.mpg' : 'video/mpeg',
|
||||
'.mpv' : 'video/matroska',
|
||||
'.mkv' : 'video/matroska',
|
||||
'.mov' : 'video/quicktime',
|
||||
'.ms' : 'application/x-troff-ms',
|
||||
'.nc' : 'application/x-netcdf',
|
||||
'.nws' : 'message/rfc822',
|
||||
'.o' : 'application/octet-stream',
|
||||
'.obj' : 'application/octet-stream',
|
||||
'.oda' : 'application/oda',
|
||||
'.ogv' : 'video/ogg',
|
||||
'.p12' : 'application/x-pkcs12',
|
||||
'.p7c' : 'application/pkcs7-mime',
|
||||
'.pbm' : 'image/x-portable-bitmap',
|
||||
'.pdf' : 'application/pdf',
|
||||
'.pfx' : 'application/x-pkcs12',
|
||||
'.pgm' : 'image/x-portable-graymap',
|
||||
'.pl' : 'text/plain',
|
||||
'.png' : 'image/png',
|
||||
'.pnm' : 'image/x-portable-anymap',
|
||||
'.pot' : 'application/vnd.ms-powerpoint',
|
||||
'.ppa' : 'application/vnd.ms-powerpoint',
|
||||
'.ppm' : 'image/x-portable-pixmap',
|
||||
'.pps' : 'application/vnd.ms-powerpoint',
|
||||
'.ppt' : 'application/vnd.ms-powerpoint',
|
||||
'.ps' : 'application/postscript',
|
||||
'.pwz' : 'application/vnd.ms-powerpoint',
|
||||
'.py' : 'text/x-python',
|
||||
'.pyc' : 'application/x-python-code',
|
||||
'.pyo' : 'application/x-python-code',
|
||||
'.qt' : 'video/quicktime',
|
||||
'.ra' : 'audio/x-pn-realaudio',
|
||||
'.ram' : 'application/x-pn-realaudio',
|
||||
'.ras' : 'image/x-cmu-raster',
|
||||
'.rdf' : 'application/xml',
|
||||
'.rgb' : 'image/x-rgb',
|
||||
'.roff' : 'application/x-troff',
|
||||
'.rtx' : 'text/richtext',
|
||||
'.sgm' : 'text/x-sgml',
|
||||
'.sgml' : 'text/x-sgml',
|
||||
'.sh' : 'application/x-sh',
|
||||
'.shar' : 'application/x-shar',
|
||||
'.snd' : 'audio/basic',
|
||||
'.so' : 'application/octet-stream',
|
||||
'.src' : 'application/x-wais-source',
|
||||
'.sv4cpio': 'application/x-sv4cpio',
|
||||
'.sv4crc' : 'application/x-sv4crc',
|
||||
'.swf' : 'application/x-shockwave-flash',
|
||||
'.t' : 'application/x-troff',
|
||||
'.tar' : 'application/x-tar',
|
||||
'.tcl' : 'application/x-tcl',
|
||||
'.tex' : 'application/x-tex',
|
||||
'.texi' : 'application/x-texinfo',
|
||||
'.texinfo': 'application/x-texinfo',
|
||||
'.tif' : 'image/tiff',
|
||||
'.tiff' : 'image/tiff',
|
||||
'.tr' : 'application/x-troff',
|
||||
'.ts' : 'video/MP2T',
|
||||
'.tsv' : 'text/tab-separated-values',
|
||||
'.txt' : 'text/plain',
|
||||
'.ustar' : 'application/x-ustar',
|
||||
'.vcf' : 'text/x-vcard',
|
||||
'.wav' : 'audio/x-wav',
|
||||
'.webm' : 'video/webm',
|
||||
'.wiz' : 'application/msword',
|
||||
'.wm' : 'video/x-ms-wm',
|
||||
'.wmv' : 'video/x-ms-wmv',
|
||||
'.wmx' : 'video/x-ms-wmx',
|
||||
'.wvx' : 'video/x-ms-wvx',
|
||||
'.wsdl' : 'application/xml',
|
||||
'.xbm' : 'image/x-xbitmap',
|
||||
'.xlb' : 'application/vnd.ms-excel',
|
||||
# Duplicates :(
|
||||
'.xls' : 'application/excel',
|
||||
'.xls' : 'application/vnd.ms-excel',
|
||||
'.xml' : 'text/xml',
|
||||
'.xpdl' : 'application/xml',
|
||||
'.xpm' : 'image/x-xpixmap',
|
||||
'.xsl' : 'application/xml',
|
||||
'.xwd' : 'image/x-xwindowdump',
|
||||
'.zip' : 'application/zip',
|
||||
}
|
||||
|
||||
# These are non-standard types, commonly found in the wild. They will
|
||||
# only match if strict=0 flag is given to the API methods.
|
||||
|
||||
# Please sort these too
|
||||
common_types = {
|
||||
'.jpg' : 'image/jpg',
|
||||
'.mid' : 'audio/midi',
|
||||
'.midi': 'audio/midi',
|
||||
'.pct' : 'image/pict',
|
||||
'.pic' : 'image/pict',
|
||||
'.pict': 'image/pict',
|
||||
'.rtf' : 'application/rtf',
|
||||
'.xul' : 'text/xul'
|
||||
}
|
||||
|
||||
|
||||
_default_mime_types()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import getopt
|
||||
|
||||
USAGE = """\
|
||||
Usage: mimetypes.py [options] type
|
||||
|
||||
Options:
|
||||
--help / -h -- print this message and exit
|
||||
--lenient / -l -- additionally search of some common, but non-standard
|
||||
types.
|
||||
--extension / -e -- guess extension instead of type
|
||||
|
||||
More than one type argument may be given.
|
||||
"""
|
||||
|
||||
def usage(code, msg=''):
|
||||
print USAGE
|
||||
if msg: print msg
|
||||
sys.exit(code)
|
||||
|
||||
try:
|
||||
opts, args = getopt.getopt(sys.argv[1:], 'hle',
|
||||
['help', 'lenient', 'extension'])
|
||||
except getopt.error, msg:
|
||||
usage(1, msg)
|
||||
|
||||
strict = 1
|
||||
extension = 0
|
||||
for opt, arg in opts:
|
||||
if opt in ('-h', '--help'):
|
||||
usage(0)
|
||||
elif opt in ('-l', '--lenient'):
|
||||
strict = 0
|
||||
elif opt in ('-e', '--extension'):
|
||||
extension = 1
|
||||
for gtype in args:
|
||||
if extension:
|
||||
guess = guess_extension(gtype, strict)
|
||||
if not guess: print "I don't know anything about type", gtype
|
||||
else: print guess
|
||||
else:
|
||||
guess, encoding = guess_type(gtype, strict)
|
||||
if not guess: print "I don't know anything about type", gtype
|
||||
else: print 'type:', guess, 'encoding:', encoding
|
|
@ -0,0 +1,38 @@
|
|||
from __future__ import absolute_import
|
||||
from platform import uname
|
||||
from .error import Error
|
||||
import sys
|
||||
import os
|
||||
|
||||
|
||||
class Platform:
|
||||
def __init__(self):
|
||||
self.arch = self.arch()
|
||||
self.system = self.system()
|
||||
|
||||
def __str__(self):
|
||||
return "%s/%s" % (self.system, self.arch)
|
||||
|
||||
@staticmethod
|
||||
def arch():
|
||||
if uname()[4].startswith('arm'):
|
||||
return 'arm'
|
||||
elif sys.maxsize > 2**32:
|
||||
return 'x64'
|
||||
else:
|
||||
return 'x86'
|
||||
|
||||
@staticmethod
|
||||
def system():
|
||||
if sys.platform.startswith('linux'):
|
||||
if 'ANDROID_DATA' in os.environ:
|
||||
return 'android'
|
||||
else:
|
||||
return 'linux'
|
||||
elif sys.platform.startswith('win'):
|
||||
return 'windows'
|
||||
elif sys.platform.startswith('darwin'):
|
||||
return 'darwin'
|
||||
else:
|
||||
raise Error("Platform %s is unknown" % sys.platform, Error.UNKNOWN_PLATFORM,
|
||||
platform=sys.platform)
|
Loading…
Reference in New Issue