Files
Noah 55a260f9da fix(stream-gears): Python signatures, types and error semantics (#1729)
* 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
2026-09-24 05:27:39 +00:00

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()