* fix(stream-gears): raise exceptions instead of failing silently in the Python API - Add `stream_gears.StreamGearsError` (subclass of `RuntimeError`, so existing `except RuntimeError` keeps working). Errors that used to raise `RuntimeError` now raise it, with the full error-stack cause chain. - `download` / `download_with_callback`: connection failures, an incomplete first packet, FLV errors mid-stream and HLS errors raise `StreamGearsError` instead of panicking (`PanicException` derives from `BaseException`) or only logging and returning. The FLV path calls `httpflv::parse_flv`, now public, so `httpflv::download` and server-side recording are unchanged. - `download_with_callback` accepts any callable. A callback exception is logged when it happens and re-raised once the download finishes; if the download fails too, it becomes the `__cause__` of the `StreamGearsError`. - `PySegment` fields are readable; `segment` also accepts a dict or an object with `time` / `size` attributes, and `desc_v2` items accept objects with `type_id` / `raw_text` / `biz_id` besides dicts. - `upload`: malformed `extra_fields` raises `ValueError` instead of being dropped; empty or blank still means none. - `login_by_sms` raises on failure instead of returning `False`; invalid `ret` JSON raises `ValueError`; tokio runtime creation errors raise instead of panicking. - The seven login functions get `proxy=None` defaults (PyO3 >= 0.24 no longer defaults trailing `Option` arguments). Ref #1721 * test(stream-gears): offline pytest cases for error semantics and argument types Ref #1721 * fix(stream-gears): turn FLV parser panics in download into StreamGearsError `httpflv::parse_flv` still `expect`s on malformed AAC/AVC/script tag data. Catch the unwind at the binding boundary so `download` raises `StreamGearsError` instead of `PanicException`, without changing the parser that server-side recording shares. Ref #1721 * test(stream-gears): update smoke suite for StreamGearsError and optional proxy The login functions now default proxy to None, so the strict xfail cases for it pass and are removed (test_errors.py covers the same assertion). StreamGearsError joins the exported names; PySegment fields are readable. * feat(stream-gears): optional cookie file path for login functions `login_by_sms`, `login_by_web_cookies` and `login_by_web_qrcode` take a `file` argument (default `"cookies.json"`, same as before) for where the login info is written. `login_by_qrcode` takes `file=None` and writes the login info there when given; it still returns the JSON string. Ref #1721
294 lines
9.6 KiB
Python
294 lines
9.6 KiB
Python
"""Error semantics and argument types of the stream_gears Python API.
|
|
|
|
Run against an installed wheel: ``pytest crates/stream-gears/tests``. All tests
|
|
are offline: downloads hit a local ``http.server`` or a closed port, and
|
|
logins go through a proxy on a closed port.
|
|
"""
|
|
|
|
import http.server
|
|
import inspect
|
|
import socket
|
|
import threading
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
import stream_gears
|
|
|
|
LOGIN_FUNCTIONS = [
|
|
"get_qrcode",
|
|
"login_by_cookies",
|
|
"login_by_qrcode",
|
|
"login_by_sms",
|
|
"login_by_web_cookies",
|
|
"login_by_web_qrcode",
|
|
"send_sms",
|
|
]
|
|
|
|
# FLV file header (9 bytes, audio + video) followed by PreviousTagSize0.
|
|
FLV_HEADER = b"FLV\x01\x05\x00\x00\x00\x09" + b"\x00\x00\x00\x00"
|
|
|
|
ROUTES = {
|
|
"/empty.flv": b"",
|
|
"/header-only.flv": FLV_HEADER,
|
|
# A tag header needs 11 bytes; the stream ends after 5.
|
|
"/truncated.flv": FLV_HEADER + b"\x09\x00\x00\x10\x00",
|
|
# A complete script tag whose 3-byte body is not valid AMF data.
|
|
"/bad-script.flv": FLV_HEADER
|
|
+ b"\x12\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00"
|
|
+ b"\x02\x00\x00"
|
|
+ b"\x00\x00\x00\x0e",
|
|
"/not-a-stream": b"<html><body>hello</body></html>",
|
|
}
|
|
|
|
|
|
class Handler(http.server.BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
body = ROUTES.get(self.path)
|
|
if body is None:
|
|
self.send_error(404)
|
|
return
|
|
self.send_response(200)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def log_message(self, *args):
|
|
pass
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def server():
|
|
httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
|
thread.start()
|
|
yield f"http://127.0.0.1:{httpd.server_address[1]}"
|
|
httpd.shutdown()
|
|
httpd.server_close()
|
|
|
|
|
|
@pytest.fixture
|
|
def closed_port():
|
|
with socket.socket() as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
return s.getsockname()[1]
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def in_tmp_path(tmp_path, monkeypatch):
|
|
# download() writes download.log and logins write cookies.json into the cwd.
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
|
|
def assert_stream_gears_error(call):
|
|
"""`call` must raise StreamGearsError, catchable with `except Exception`.
|
|
|
|
Catches BaseException so that a Rust panic (pyo3 PanicException derives
|
|
from BaseException) fails the assertion instead of aborting the run.
|
|
"""
|
|
try:
|
|
call()
|
|
except BaseException as e: # noqa: B036
|
|
assert isinstance(e, Exception), f"{type(e).__name__} is not an Exception: {e}"
|
|
assert type(e).__name__ == "StreamGearsError", f"{type(e).__name__}: {e}"
|
|
return e
|
|
pytest.fail("did not raise")
|
|
|
|
|
|
def download(url, tmp_path, segment=None, **kwargs):
|
|
if segment is None:
|
|
segment = stream_gears.PySegment()
|
|
return stream_gears.download_with_callback(
|
|
url, {}, str(tmp_path / "out"), segment, **kwargs
|
|
)
|
|
|
|
|
|
def test_stream_gears_error_hierarchy():
|
|
assert issubclass(stream_gears.StreamGearsError, RuntimeError)
|
|
assert issubclass(stream_gears.StreamGearsError, Exception)
|
|
|
|
|
|
@pytest.mark.parametrize("name", LOGIN_FUNCTIONS)
|
|
def test_login_proxy_defaults_to_none(name):
|
|
params = inspect.signature(getattr(stream_gears, name)).parameters
|
|
assert params["proxy"].default is None
|
|
|
|
|
|
@pytest.mark.parametrize("name", ["download", "download_with_callback"])
|
|
def test_download_signature(name):
|
|
params = inspect.signature(getattr(stream_gears, name)).parameters
|
|
assert list(params)[:4] == ["url", "header_map", "file_name", "segment"]
|
|
assert params["proxy"].default is None
|
|
|
|
|
|
def test_download_closed_port(tmp_path, closed_port):
|
|
assert_stream_gears_error(
|
|
lambda: download(f"http://127.0.0.1:{closed_port}/live.flv", tmp_path)
|
|
)
|
|
|
|
|
|
def test_download_http_error(server, tmp_path):
|
|
e = assert_stream_gears_error(lambda: download(f"{server}/missing", tmp_path))
|
|
assert "404" in str(e)
|
|
|
|
|
|
def test_download_empty_response(server, tmp_path):
|
|
e = assert_stream_gears_error(lambda: download(f"{server}/empty.flv", tmp_path))
|
|
assert "incomplete" in str(e)
|
|
|
|
|
|
def test_download_truncated_flv(server, tmp_path):
|
|
assert_stream_gears_error(lambda: download(f"{server}/truncated.flv", tmp_path))
|
|
|
|
|
|
def test_download_malformed_flv_tag(server, tmp_path):
|
|
e = assert_stream_gears_error(lambda: download(f"{server}/bad-script.flv", tmp_path))
|
|
assert "script tag" in str(e)
|
|
|
|
|
|
def test_download_hls_error(server, tmp_path):
|
|
e = assert_stream_gears_error(lambda: download(f"{server}/not-a-stream", tmp_path))
|
|
assert "HLS" in str(e)
|
|
|
|
|
|
def test_download_plain_function_rejects_bad_segment(server, tmp_path):
|
|
with pytest.raises(TypeError):
|
|
stream_gears.download(f"{server}/header-only.flv", {}, str(tmp_path / "out"), 42)
|
|
|
|
|
|
def test_callback_accepts_lambda(server, tmp_path):
|
|
names = []
|
|
download(f"{server}/header-only.flv", tmp_path, file_name_callback_fn=lambda n: names.append(n))
|
|
assert names == [str(tmp_path / "out.flv")]
|
|
assert (tmp_path / "out.flv").is_file()
|
|
|
|
|
|
def test_callback_accepts_bound_method(server, tmp_path):
|
|
names = []
|
|
download(f"{server}/header-only.flv", tmp_path, file_name_callback_fn=names.append)
|
|
assert names == [str(tmp_path / "out.flv")]
|
|
|
|
|
|
def test_callback_must_be_callable(server, tmp_path):
|
|
with pytest.raises(TypeError, match="callable"):
|
|
download(f"{server}/header-only.flv", tmp_path, file_name_callback_fn=42)
|
|
|
|
|
|
def test_callback_exception_is_reraised(server, tmp_path):
|
|
def callback(name):
|
|
raise ValueError(f"boom {name}")
|
|
|
|
with pytest.raises(ValueError, match="boom"):
|
|
download(f"{server}/header-only.flv", tmp_path, file_name_callback_fn=callback)
|
|
# The download itself finished before the callback error was re-raised.
|
|
assert (tmp_path / "out.flv").is_file()
|
|
|
|
|
|
def test_py_segment_fields_are_readable():
|
|
segment = stream_gears.PySegment()
|
|
assert segment.time is None and segment.size is None
|
|
segment.time = 60
|
|
segment.size = 10 * 1000 * 1000
|
|
assert (segment.time, segment.size) == (60, 10 * 1000 * 1000)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"segment",
|
|
[
|
|
{"time": 3600},
|
|
{"size": 1 << 30, "time": None},
|
|
{},
|
|
SimpleNamespace(time=3600, size=None),
|
|
SimpleNamespace(size=1 << 30),
|
|
],
|
|
ids=["dict-time", "dict-size", "dict-empty", "object", "object-size-only"],
|
|
)
|
|
def test_segment_accepts_dict_and_object(server, tmp_path, segment):
|
|
download(f"{server}/header-only.flv", tmp_path, segment=segment)
|
|
assert (tmp_path / "out.flv").is_file()
|
|
|
|
|
|
def upload(tmp_path, **kwargs):
|
|
kwargs.setdefault("cookie_file", str(tmp_path / "no-such-cookies.json"))
|
|
return stream_gears.upload([str(tmp_path / "video.flv")], title="t", **kwargs)
|
|
|
|
|
|
@pytest.mark.parametrize("extra_fields", ['{"a":1,}', "[1, 2]", "{"])
|
|
def test_upload_invalid_extra_fields(tmp_path, extra_fields):
|
|
with pytest.raises(ValueError, match="extra_fields"):
|
|
upload(tmp_path, extra_fields=extra_fields)
|
|
|
|
|
|
@pytest.mark.parametrize("extra_fields", ["", '{"a": 1}'])
|
|
def test_upload_valid_extra_fields_reach_login(tmp_path, extra_fields):
|
|
# Parsing passes; the missing cookie file is the next (offline) failure.
|
|
assert_stream_gears_error(lambda: upload(tmp_path, extra_fields=extra_fields))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"credit",
|
|
[
|
|
{"type": 1, "raw_text": "hi", "biz_id": ""},
|
|
{"type": 1, "raw_text": "hi"},
|
|
SimpleNamespace(type_id=1, raw_text="hi", biz_id=None),
|
|
],
|
|
ids=["dict", "dict-no-biz-id", "object"],
|
|
)
|
|
def test_upload_desc_v2_accepts_dict_and_object(tmp_path, credit):
|
|
assert_stream_gears_error(lambda: upload(tmp_path, desc_v2=[credit]))
|
|
|
|
|
|
def test_upload_desc_v2_missing_field(tmp_path):
|
|
with pytest.raises(TypeError, match="raw_text"):
|
|
upload(tmp_path, desc_v2=[{"type": 1}])
|
|
|
|
|
|
@pytest.mark.parametrize("name", ["login_by_sms", "login_by_qrcode"])
|
|
def test_login_invalid_json_raises_value_error(name):
|
|
args = (123456, "{not json") if name == "login_by_sms" else ("{not json",)
|
|
with pytest.raises(ValueError, match="ret"):
|
|
getattr(stream_gears, name)(*args, proxy=None)
|
|
|
|
|
|
def test_login_by_sms_failure_raises(tmp_path, closed_port):
|
|
e = assert_stream_gears_error(
|
|
lambda: stream_gears.login_by_sms(
|
|
123456,
|
|
'{"captcha_key": "x", "tel": "13800000000", "cid": 86}',
|
|
proxy=f"http://127.0.0.1:{closed_port}",
|
|
)
|
|
)
|
|
assert str(e)
|
|
assert not (tmp_path / "cookies.json").exists()
|
|
|
|
|
|
def test_login_by_cookies_missing_file(tmp_path):
|
|
e = assert_stream_gears_error(
|
|
lambda: stream_gears.login_by_cookies(str(tmp_path / "no-such-cookies.json"))
|
|
)
|
|
assert "no-such-cookies.json" in str(e) or "No such file" in str(e)
|
|
|
|
|
|
@pytest.mark.parametrize("name", ["login_by_sms", "login_by_web_cookies", "login_by_web_qrcode"])
|
|
def test_login_file_defaults_to_cookies_json(name):
|
|
params = inspect.signature(getattr(stream_gears, name)).parameters
|
|
assert params["file"].default == "cookies.json"
|
|
|
|
|
|
def test_login_by_qrcode_file_is_optional():
|
|
params = inspect.signature(stream_gears.login_by_qrcode).parameters
|
|
assert params["file"].default is None
|
|
|
|
|
|
def test_login_by_sms_failure_does_not_write_file(tmp_path, closed_port):
|
|
target = tmp_path / "sub" / "my-cookies.json"
|
|
assert_stream_gears_error(
|
|
lambda: stream_gears.login_by_sms(
|
|
123456,
|
|
'{"captcha_key": "x", "tel": "13800000000", "cid": 86}',
|
|
proxy=f"http://127.0.0.1:{closed_port}",
|
|
file=target,
|
|
)
|
|
)
|
|
assert not target.exists()
|
|
assert not (tmp_path / "cookies.json").exists()
|