Windows command line parsing fix (#300)

* Enable automated testing on Windows via AppVeyor

* tests: add some tests for the command line argument parsing

* cli: fixed windows argument parsing with escaped quotes, etc.

* tests: python2.6 requires unittest2

* tests: fixed awful typo!
This commit is contained in:
Beardypig 2016-12-15 20:47:22 +01:00 committed by Forrest
parent 826dd87cc7
commit f29b08c58c
10 changed files with 217 additions and 14 deletions

View File

@ -1,5 +1,4 @@
language: python
sudo: required
matrix:
include:
@ -11,8 +10,8 @@ matrix:
env: BUILD_DOCS=yes BUILD_INSTALLER=yes STREAMLINK_INSTALLER_DIST_DIR=$TRAVIS_BUILD_DIR/dist/nsis
before_install:
- pip install pytest pytest-cov codecov coverage mock pynsist
- sudo pip install s3cmd
- pip install --disable-pip-version-check --upgrade pip
- pip install -r dev-requirements.txt
install:
- python setup.py install

View File

@ -5,5 +5,6 @@ include LICENSE*
include requirements-docs.txt
recursive-include docs *
prune docs/_build
recursive-include examples *
recursive-include tests *py

49
appveyor.yml Normal file
View File

@ -0,0 +1,49 @@
environment:
matrix:
- PYTHON: "C:\\Python27"
- PYTHON: "C:\\Python33"
- PYTHON: "C:\\Python34"
- PYTHON: "C:\\Python35"
- PYTHON: "C:\\Python27-x64"
- PYTHON: "C:\\Python33-x64"
DISTUTILS_USE_SDK: "1"
- PYTHON: "C:\\Python34-x64"
DISTUTILS_USE_SDK: "1"
- PYTHON: "C:\\Python35-x64"
install:
# If there is a newer build queued for the same PR, cancel this one.
# The AppVeyor 'rollout builds' option is supposed to serve the same
# purpose but it is problematic because it tends to cancel builds pushed
# directly to master instead of just PR builds (or the converse).
# credits: JuliaLang developers.
- ps: if ($env:APPVEYOR_PULL_REQUEST_NUMBER -and $env:APPVEYOR_BUILD_NUMBER -ne ((Invoke-RestMethod `
https://ci.appveyor.com/api/projects/$env:APPVEYOR_ACCOUNT_NAME/$env:APPVEYOR_PROJECT_SLUG/history?recordsNumber=50).builds | `
Where-Object pullRequestId -eq $env:APPVEYOR_PULL_REQUEST_NUMBER)[0].buildNumber) { `
throw "There are newer queued builds for this pull request, failing early." }
# Install Python (from the official .msi of http://python.org) and pip when
# not already installed.
- ps: if (-not(Test-Path($env:PYTHON))) { & appveyor\install.ps1 }
# Prepend newly installed Python to the PATH of this build (this cannot be
# done from inside the powershell script as it would require to restart
# the parent CMD process).
- "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%"
# Check that we have the expected version and architecture for Python
- "python --version"
- "python -c \"import struct; print(struct.calcsize('P') * 8)\""
# Upgrade to the latest version of pip to avoid it displaying warnings
# about it being out of date.
- "pip install --disable-pip-version-check --user --upgrade pip"
# install dev requirements, for testing, etc.
- "pip install -r dev-requirements.txt"
build: off
test_script:
- "build.cmd %PYTHON%\\python.exe setup.py test"

21
build.cmd Normal file
View File

@ -0,0 +1,21 @@
@echo off
:: To build extensions for 64 bit Python 3, we need to configure environment
:: variables to use the MSVC 2010 C++ compilers from GRMSDKX_EN_DVD.iso of:
:: MS Windows SDK for Windows 7 and .NET Framework 4
::
:: More details at:
:: https://github.com/cython/cython/wiki/64BitCythonExtensionsOnWindows
IF "%DISTUTILS_USE_SDK%"=="1" (
ECHO Configuring environment to build with MSVC on a 64bit architecture
ECHO Using Windows SDK 7.1
"C:\Program Files\Microsoft SDKs\Windows\v7.1\Setup\WindowsSdkVer.exe" -q -version:v7.1
CALL "C:\Program Files\Microsoft SDKs\Windows\v7.1\Bin\SetEnv.cmd" /x64 /release
SET MSSdk=1
REM Need the following to allow tox to see the SDK compiler
SET TOX_TESTENV_PASSENV=DISTUTILS_USE_SDK MSSdk INCLUDE LIB
) ELSE (
ECHO Using default MSVC build environment
)
CALL %*

8
dev-requirements.txt Normal file
View File

@ -0,0 +1,8 @@
pip>=6
pytest
pytest-cov
codecov
coverage
mock
pynsist
unittest2; python_version < '2.7'

View File

@ -5,6 +5,8 @@ import sys
from time import sleep
import re
from .compat import is_win32, stdout
from .constants import DEFAULT_PLAYER_ARGUMENTS
from .utils import ignored
@ -45,6 +47,7 @@ class Output(object):
class FileOutput(Output):
def __init__(self, filename=None, fd=None):
super(FileOutput, self).__init__()
self.filename = filename
self.fd = fd
@ -64,9 +67,9 @@ class FileOutput(Output):
class PlayerOutput(Output):
def __init__(self, cmd, args=DEFAULT_PLAYER_ARGUMENTS,
filename=None, quiet=True, kill=True,
call=False, http=False, namedpipe=None):
def __init__(self, cmd, args=DEFAULT_PLAYER_ARGUMENTS, filename=None, quiet=True, kill=True, call=False, http=False,
namedpipe=None):
super(PlayerOutput, self).__init__()
self.cmd = cmd
self.args = args
self.kill = kill
@ -108,10 +111,7 @@ class PlayerOutput(Output):
args = self.args.format(filename=filename)
cmd = self.cmd
if is_win32:
# We want to keep the backslashes on Windows as forcing the user to
# escape backslashes for paths would be inconvenient.
cmd = cmd.replace("\\", "\\\\")
args = args.replace("\\", "\\\\")
return cmd + " " + args
return shlex.split(cmd) + shlex.split(args)
@ -139,7 +139,6 @@ class PlayerOutput(Output):
stdin=self.stdin, bufsize=0,
stdout=self.stdout,
stderr=self.stderr)
# Wait 0.5 seconds to see if program exited prematurely
if not self.running:
raise OSError("Process exited prematurely")

View File

@ -1,7 +1,7 @@
import os
import sys
from ..compat import shlex_quote
import subprocess
def check_paths(exes, paths):
@ -35,5 +35,5 @@ def find_default_player():
if path:
# Quote command because it can contain space
return shlex_quote(path)
return subprocess.list2cmdline([path])

View File

@ -1,9 +1,20 @@
from io import BytesIO
from itertools import repeat
from streamlink.plugins import Plugin
from streamlink.options import Options
from streamlink.stream import *
from streamlink.plugin.api.support_plugin import testplugin_support
class TestStream(Stream):
__shortname__ = "test"
def open(self):
return BytesIO(b'x'*8192*2)
class TestPlugin(Plugin):
options = Options({
"a_option": "default"
@ -15,6 +26,7 @@ class TestPlugin(Plugin):
def _get_streams(self):
streams = {}
streams["test"] = TestStream(self.session)
streams["rtmp"] = RTMPStream(self.session, dict(rtmp="rtmp://test.se"))
streams["hls"] = HLSStream(self.session, "http://test.se/playlist.m3u8")
streams["http"] = HTTPStream(self.session, "http://test.se/stream")

112
tests/test_cmdline.py Normal file
View File

@ -0,0 +1,112 @@
import sys
if sys.version_info[0:2] == (2, 6):
import unittest2 as unittest
else:
import unittest
import os.path
import streamlink_cli.main
from mock import patch, ANY
from streamlink import Streamlink
from streamlink_cli.compat import is_win32
PluginPath = os.path.join(os.path.dirname(__file__), "plugins")
def setup_streamlink():
streamlink_cli.main.streamlink = Streamlink()
streamlink_cli.main.streamlink.load_plugins(PluginPath)
return streamlink_cli.main.streamlink
class TestCommandLineInvocation(unittest.TestCase):
"""
Test that when invoked for the command line arguments are parsed as expected
"""
@patch('streamlink_cli.main.setup_streamlink', side_effect=setup_streamlink)
@patch('subprocess.Popen')
@patch('sys.argv')
def _test_args(self, args, commandline, mock_argv, mock_popen, mock_setup_streamlink, passthrough=False):
mock_argv.__getitem__.side_effect = lambda x: args[x]
mock_popen().returncode = None
mock_popen().poll.return_value = None
streamlink_cli.main.main()
mock_setup_streamlink.assert_called_with()
if not passthrough:
mock_popen.assert_called_with(commandline, stderr=ANY, stdout=ANY, bufsize = ANY, stdin = ANY)
else:
mock_popen.assert_called_with(commandline, stderr=ANY, stdout=ANY)
#
# POSIX tests
#
@unittest.skipIf(is_win32, "test only applicable in a POSIX OS")
def test_open_regular_path_player(self):
self._test_args(["streamlink", "-p", "/usr/bin/vlc", "http://test.se", "test"],
["/usr/bin/vlc", "-"])
@unittest.skipIf(is_win32, "test only applicable in a POSIX OS")
def test_open_space_path_player(self):
self._test_args(["streamlink", "-p", "\"/Applications/Video Player/VLC/vlc\"", "http://test.se", "test"],
["/Applications/Video Player/VLC/vlc", "-"])
# escaped
self._test_args(["streamlink", "-p", "/Applications/Video\ Player/VLC/vlc", "http://test.se", "test"],
["/Applications/Video Player/VLC/vlc", "-"])
@unittest.skipIf(is_win32, "test only applicable in a POSIX OS")
def test_open_player_extra_args_in_player(self):
self._test_args(["streamlink", "-p", "/usr/bin/vlc",
"-a", '''--input-title-format "Poker \\"Stars\\"" {filename}''',
"http://test.se", "test"],
["/usr/bin/vlc", "--input-title-format", 'Poker "Stars"', "-"])
@unittest.skipIf(is_win32, "test only applicable in a POSIX OS")
def test_open_player_extra_args_in_player_pass_through(self):
self._test_args(["streamlink", "--player-passthrough", "rtmp", "-p", "/usr/bin/vlc",
"-a", '''--input-title-format "Poker \\"Stars\\"" {filename}''',
"test.se", "rtmp"],
["/usr/bin/vlc", "--input-title-format", 'Poker "Stars"', "rtmp://test.se"],
passthrough=True)
#
# Windows Tests
#
@unittest.skipIf(not is_win32, "test only applicable on Windows")
def test_open_space_path_player_win32(self):
self._test_args(["streamlink", "-p", "c:\\Program Files\\VideoLAN\VLC\\vlc.exe", "http://test.se", "test"],
"c:\\Program Files\\VideoLAN\\VLC\\vlc.exe -")
@unittest.skipIf(not is_win32, "test only applicable on Windows")
def test_open_space_quote_path_player_win32(self):
self._test_args(["streamlink", "-p", "\"c:\\Program Files\\VideoLAN\VLC\\vlc.exe\"", "http://test.se", "test"],
"\"c:\\Program Files\\VideoLAN\\VLC\\vlc.exe\" -")
@unittest.skipIf(not is_win32, "test only applicable on Windows")
def test_open_player_args_with_quote_in_player_win32(self):
self._test_args(["streamlink", "-p",
'''c:\\Program Files\\VideoLAN\VLC\\vlc.exe --input-title-format "Poker \\"Stars\\""''',
"http://test.se", "test"],
'''c:\\Program Files\\VideoLAN\VLC\\vlc.exe --input-title-format "Poker \\"Stars\\"" -''')
@unittest.skipIf(not is_win32, "test only applicable on Windows")
def test_open_player_extra_args_in_player_win32(self):
self._test_args(["streamlink", "-p", "c:\\Program Files\\VideoLAN\VLC\\vlc.exe",
"-a", '''--input-title-format "Poker \\"Stars\\"" {filename}''',
"http://test.se", "test"],
'''c:\\Program Files\\VideoLAN\VLC\\vlc.exe --input-title-format "Poker \\"Stars\\"" -''')
@unittest.skipIf(not is_win32, "test only applicable on Windows")
def test_open_player_extra_args_in_player_pass_through_win32(self):
self._test_args(["streamlink", "--player-passthrough", "rtmp", "-p", "c:\\Program Files\\VideoLAN\VLC\\vlc.exe",
"-a", '''--input-title-format "Poker \\"Stars\\"" {filename}''',
"test.se", "rtmp"],
'''c:\\Program Files\\VideoLAN\VLC\\vlc.exe --input-title-format "Poker \\"Stars\\"" \"rtmp://test.se\"''',
passthrough=True)
if __name__ == "__main__":
unittest.main()

View File

@ -13,7 +13,9 @@ from streamlink.plugin.api.http_session import HTTPSession
class TestPluginAPIHTTPSession(unittest.TestCase):
def test_read_timeout(self):
@patch('requests.sessions.Session.send')
def test_read_timeout(self, mock_send):
mock_send.side_effect = IOError
session = HTTPSession()
def stream_data():