feat(stream-gears): ship stream_gears package with accurate stubs (#1732)
* feat(stream-gears): ship stream_gears package with accurate stubs The stream_gears/ package (__init__.py, stub, py.typed) never made it into the wheel: pyproject only listed python-packages = ["biliup"], so maturin generated its own __init__.py and the stub drifted from the implementation (7 of 16 upload lines, login_by_qrcode typed as bool, missing upload parameters, pyobject.Segment/Credit not accepted by the Rust side). - Move the package to the repo root next to biliup/ (maturin resolves python-packages relative to python-source) and set module-name = "stream_gears.stream_gears" for a mixed layout. - __init__.py re-exports the native module, adds Segment = PySegment and a Credit TypedDict; pyobject.py is removed. - Rewrite stream_gears.pyi from the implementation, including StreamGearsError and the proxy/file parameters. - Give upload an explicit text_signature so its string and list defaults show up in inspect.signature/help() instead of Ellipsis. Top-level names only grow: Credit and Segment are added. * ci(desktop): keep PyInstaller off the source stream_gears package PyInstaller puts the repo root first on its search path, so the source stream_gears/ (which has no native module) shadowed the installed package and the frozen biliup failed to import stream_gears.stream_gears. Remove the source copy after installing the wheel. * test(stream-gears): check package layout and run stubtest in CI test_package.py covers the new top-level names, the native submodule, __all__ staying in sync with the native module, py.typed and the stub in the wheel, upload's text_signature against PyO3's argument parsing, and mypy --strict on a valid and an invalid example. CI runs python -m mypy.stubtest stream_gears against the installed wheel so the stub cannot drift from the implementation again. * build(stream-gears): drop unused danmaku-client and async-trait deps Neither is referenced anywhere in crates/stream-gears. danmaku is still built through biliup-cli, so this only trims the manifest and lockfile entries. * refactor(stream-gears): keep the package in crates/stream-gears via python-source Point maturin's python-source at crates/stream-gears instead of moving stream_gears/ to the repository root. python-packages is resolved under python-source, so the root biliup/ package is shipped through include. The wheel contents are unchanged. * ci: drop workarounds for a root-level stream_gears package With stream_gears/ back in crates/stream-gears, nothing at the repository root shadows the installed package, so PyInstaller and stubtest no longer need to avoid it.
This commit is contained in:
11 files changed
+430
-215
No files matched your search
@@ -64,8 +64,12 @@ jobs:
|
||||
if: matrix.platform.target == 'x86_64'
|
||||
run: |
|
||||
python -m venv /tmp/smoke-venv
|
||||
/tmp/smoke-venv/bin/pip install dist/biliup-*-abi3-*.whl pytest
|
||||
# mypy is pinned because new stubtest releases add checks.
|
||||
/tmp/smoke-venv/bin/pip install dist/biliup-*-abi3-*.whl pytest mypy==2.3.1
|
||||
/tmp/smoke-venv/bin/pytest crates/stream-gears/tests -v
|
||||
- name: Check type stubs against the wheel
|
||||
if: matrix.platform.target == 'x86_64'
|
||||
run: /tmp/smoke-venv/bin/python -m mypy.stubtest stream_gears
|
||||
- name: Build free-threaded wheels
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
|
||||
+3
-1
@@ -56,4 +56,6 @@ cookies.json
|
||||
.cargo/
|
||||
logs/
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
.claude/
|
||||
/crates/stream-gears/stream_gears/*.so
|
||||
/crates/stream-gears/stream_gears/*.pyd
|
||||
Generated
-2
@@ -4412,12 +4412,10 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
name = "stream-gears"
|
||||
version = "1.2.8"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"biliup",
|
||||
"biliup-cli",
|
||||
"bon",
|
||||
"danmaku",
|
||||
"error-stack",
|
||||
"nom 8.0.0",
|
||||
"pyo3",
|
||||
|
||||
@@ -26,10 +26,8 @@ tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||
tracing-appender = { workspace = true }
|
||||
biliup = { path = "../biliup"}
|
||||
biliup-cli = { path = "../biliup-cli"}
|
||||
danmaku-client = { package = "danmaku", path = "../danmaku"}
|
||||
time = { workspace = true }
|
||||
bon = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
error-stack = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
@@ -431,6 +431,9 @@ fn login_by_web_qrcode(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (video_path, cookie_file, title, tid=171, tid_v2=None, tag="".to_string(), copyright=2, source="".to_string(), desc="".to_string(), dynamic="".to_string(), cover="".to_string(), dolby=0, lossless_music=0, no_reprint=0, charging_pay=0, up_close_reply=false, up_selection_reply=false, up_close_danmu=false, limit=3, desc_v2=vec![], dtime=None, line=None, extra_fields="".to_string(), submit=None, proxy=None))]
|
||||
#[pyo3(
|
||||
text_signature = "(video_path, cookie_file, title, tid=171, tid_v2=None, tag='', copyright=2, source='', desc='', dynamic='', cover='', dolby=0, lossless_music=0, no_reprint=0, charging_pay=0, up_close_reply=False, up_selection_reply=False, up_close_danmu=False, limit=3, desc_v2=[], dtime=None, line=None, extra_fields='', submit=None, proxy=None)"
|
||||
)]
|
||||
fn upload(
|
||||
py: Python<'_>,
|
||||
video_path: Vec<PathBuf>,
|
||||
|
||||
@@ -1,7 +1,41 @@
|
||||
from .stream_gears import *
|
||||
from .pyobject import Segment, Credit
|
||||
import typing as _typing
|
||||
|
||||
from . import stream_gears
|
||||
from .stream_gears import *
|
||||
from .stream_gears import PySegment
|
||||
|
||||
__all__ = ['Segment', 'Credit']
|
||||
__doc__ = stream_gears.__doc__
|
||||
if hasattr(stream_gears, "__all__"):
|
||||
__all__.extend(stream_gears.__all__)
|
||||
# Literal so type checkers see the exports; the tests keep it in sync with the
|
||||
# native module's `__all__`.
|
||||
__all__ = [
|
||||
"Credit",
|
||||
"PySegment",
|
||||
"Segment",
|
||||
"StreamGearsError",
|
||||
"UploadLine",
|
||||
"config_bindings",
|
||||
"download",
|
||||
"download_with_callback",
|
||||
"get_qrcode",
|
||||
"login_by_cookies",
|
||||
"login_by_qrcode",
|
||||
"login_by_sms",
|
||||
"login_by_web_cookies",
|
||||
"login_by_web_qrcode",
|
||||
"main_loop",
|
||||
"send_sms",
|
||||
"upload",
|
||||
]
|
||||
|
||||
Segment = PySegment
|
||||
|
||||
|
||||
class _CreditRequired(_typing.TypedDict):
|
||||
type: int
|
||||
raw_text: str
|
||||
|
||||
|
||||
class Credit(_CreditRequired, total=False):
|
||||
"""`upload` 的 `desc_v2` 元素:`type` 为 1 时是普通文本,为 2 时 `raw_text` 是被 @ 的用户名、`biz_id` 是其 mid。"""
|
||||
|
||||
biz_id: _typing.Optional[str]
|
||||
@@ -1,36 +0,0 @@
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Segment:
|
||||
"""视频分段设置"""
|
||||
|
||||
@staticmethod
|
||||
def by_time(time: int) -> 'Segment':
|
||||
"""
|
||||
按时长分段
|
||||
|
||||
:param int time: 分段时长, 单位为秒
|
||||
:return: 视频分段设置
|
||||
"""
|
||||
segment = Segment()
|
||||
segment.time = time
|
||||
return segment
|
||||
|
||||
@staticmethod
|
||||
def by_size(size: int) -> 'Segment':
|
||||
"""
|
||||
按大小分段
|
||||
|
||||
:param int size: 分段大小, 单位为字节
|
||||
:return: 视频分段设置
|
||||
"""
|
||||
segment = Segment()
|
||||
segment.size = size
|
||||
return segment
|
||||
|
||||
|
||||
class Credit:
|
||||
# FIXME: docstring
|
||||
type_id: int
|
||||
raw_text: str
|
||||
biz_id: Optional[str]
|
||||
@@ -1,185 +1,261 @@
|
||||
from typing import Dict, List, Optional, Callable
|
||||
from enum import Enum
|
||||
import os
|
||||
from typing import Any, Callable, ClassVar, Mapping, Optional, Protocol, Sequence, Union, final
|
||||
|
||||
from .pyobject import Segment, Credit
|
||||
from typing_extensions import TypeAlias, deprecated
|
||||
|
||||
from stream_gears import Credit
|
||||
|
||||
def download(url: str,
|
||||
header_map: Dict[str, str],
|
||||
file_name: str,
|
||||
segment: Segment,
|
||||
proxy: Optional[str]) -> None:
|
||||
"""
|
||||
下载视频
|
||||
__all__ = [
|
||||
"PySegment",
|
||||
"StreamGearsError",
|
||||
"UploadLine",
|
||||
"config_bindings",
|
||||
"download",
|
||||
"download_with_callback",
|
||||
"get_qrcode",
|
||||
"login_by_cookies",
|
||||
"login_by_qrcode",
|
||||
"login_by_sms",
|
||||
"login_by_web_cookies",
|
||||
"login_by_web_qrcode",
|
||||
"main_loop",
|
||||
"send_sms",
|
||||
"upload",
|
||||
]
|
||||
|
||||
:param str url: 视频地址
|
||||
:param Dict[str, str] header_map: HTTP请求头
|
||||
:param str file_name: 文件名格式
|
||||
:param Segment segment: 视频分段设置
|
||||
:param Optional[str] proxy: 代理
|
||||
_StrPath: TypeAlias = Union[str, "os.PathLike[str]"]
|
||||
|
||||
class StreamGearsError(RuntimeError):
|
||||
"""下载、登录或上传失败时抛出。是 `RuntimeError` 的子类。"""
|
||||
|
||||
@final
|
||||
class PySegment:
|
||||
"""视频分段设置。`time`(秒)和 `size`(字节)都为 `None` 时不分段。"""
|
||||
|
||||
time: Optional[int]
|
||||
size: Optional[int]
|
||||
def __init__(self) -> None: ...
|
||||
|
||||
class _SegmentObject(Protocol):
|
||||
@property
|
||||
def time(self) -> Optional[int]: ...
|
||||
@property
|
||||
def size(self) -> Optional[int]: ...
|
||||
|
||||
_SegmentArg: TypeAlias = Union[PySegment, Mapping[str, Optional[int]], _SegmentObject]
|
||||
|
||||
class _CreditObject(Protocol):
|
||||
@property
|
||||
def type_id(self) -> int: ...
|
||||
@property
|
||||
def raw_text(self) -> str: ...
|
||||
|
||||
@final
|
||||
class UploadLine:
|
||||
"""上传线路。`upload` 不传 `line` 时自动测速选择。
|
||||
|
||||
`Cn*`、`An*`、`At*` 是同一 CDN 经不同接入域名的变体。
|
||||
"""
|
||||
|
||||
|
||||
def download_with_callback(url: str,
|
||||
header_map: Dict[str, str],
|
||||
file_name: str,
|
||||
segment: Segment,
|
||||
file_name_callback_fn: Callable[[str], None],
|
||||
proxy: Optional[str]) -> None:
|
||||
"""
|
||||
下载视频
|
||||
|
||||
:param str url: 视频地址
|
||||
:param Dict[str, str] header_map: HTTP请求头
|
||||
:param str file_name: 文件名格式
|
||||
:param Segment segment: 视频分段设置
|
||||
:param Callable[[str], None] file_name_callback_fn: 回调已下载完成文件名
|
||||
:param Optional[str] proxy: 代理
|
||||
"""
|
||||
|
||||
|
||||
def login_by_cookies(proxy: Optional[str]) -> bool:
|
||||
"""
|
||||
cookie登录
|
||||
|
||||
:param Optional[str] proxy: 代理
|
||||
:return: 是否登录成功
|
||||
"""
|
||||
|
||||
|
||||
def send_sms(country_code: int, phone: int, proxy: Optional[str]) -> str:
|
||||
"""
|
||||
发送短信验证码
|
||||
|
||||
:param int country_code: 国家/地区代码
|
||||
:param int phone: 手机号
|
||||
:param Optional[str] proxy: 代理
|
||||
:return: 短信登录JSON信息
|
||||
"""
|
||||
|
||||
|
||||
def login_by_sms(code: int, ret: str, proxy: Optional[str]) -> bool:
|
||||
"""
|
||||
短信登录
|
||||
|
||||
:param int code: 验证码
|
||||
:param str ret: 短信登录JSON信息
|
||||
:param Optional[str] proxy: 代理
|
||||
:return: 是否登录成功
|
||||
"""
|
||||
|
||||
|
||||
def get_qrcode(proxy: Optional[str]) -> str:
|
||||
"""
|
||||
获取二维码
|
||||
|
||||
:param Optional[str] proxy: 代理
|
||||
:return: 二维码登录JSON信息
|
||||
"""
|
||||
|
||||
|
||||
def login_by_qrcode(ret: str, proxy: Optional[str]) -> bool:
|
||||
"""
|
||||
二维码登录
|
||||
|
||||
:param str ret: 二维码登录JSON信息
|
||||
:param Optional[str] proxy: 代理
|
||||
:return: 是否登录成功
|
||||
"""
|
||||
|
||||
|
||||
def login_by_web_cookies(sess_data: str, bili_jct: str, proxy: Optional[str]) -> bool:
|
||||
"""
|
||||
网页Cookie登录1
|
||||
|
||||
:param str sess_data: SESSDATA
|
||||
:param str bili_jct: bili_jct
|
||||
:param Optional[str] proxy: 代理
|
||||
:return: 是否登录成功
|
||||
"""
|
||||
|
||||
|
||||
def login_by_web_qrcode(sess_data: str, dede_user_id: str, proxy: Optional[str]) -> bool:
|
||||
"""
|
||||
网页Cookie登录2
|
||||
|
||||
:param str sess_data: SESSDATA
|
||||
:param str dede_user_id: DedeUserID
|
||||
:param Optional[str] proxy: 代理
|
||||
:return: 是否登录成功
|
||||
"""
|
||||
|
||||
|
||||
class UploadLine(Enum):
|
||||
"""上传线路"""
|
||||
|
||||
Bda2 = 1
|
||||
Bldsa: ClassVar[UploadLine]
|
||||
"""B站大陆动态加速"""
|
||||
Cnbldsa: ClassVar[UploadLine]
|
||||
Andsa: ClassVar[UploadLine]
|
||||
Atdsa: ClassVar[UploadLine]
|
||||
Bda2: ClassVar[UploadLine]
|
||||
"""百度云"""
|
||||
|
||||
Tx = 4
|
||||
Cnbd: ClassVar[UploadLine]
|
||||
Anbd: ClassVar[UploadLine]
|
||||
Atbd: ClassVar[UploadLine]
|
||||
Tx: ClassVar[UploadLine]
|
||||
"""腾讯云EO"""
|
||||
|
||||
Txa = 5
|
||||
Cntx: ClassVar[UploadLine]
|
||||
Antx: ClassVar[UploadLine]
|
||||
Attx: ClassVar[UploadLine]
|
||||
Txa: ClassVar[UploadLine]
|
||||
"""腾讯云EO海外"""
|
||||
|
||||
Bldsa = 6
|
||||
"""Bilibili大陆动态加速"""
|
||||
|
||||
Alia = 7
|
||||
Alia: ClassVar[UploadLine]
|
||||
"""阿里云海外"""
|
||||
|
||||
Estx = 8
|
||||
Estx: ClassVar[UploadLine]
|
||||
"""B站自建"""
|
||||
|
||||
Akbd = 9
|
||||
Akbd: ClassVar[UploadLine]
|
||||
"""B站自建"""
|
||||
def __int__(self) -> int: ...
|
||||
|
||||
class _ConfigState:
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""`key` 不存在且未给 `default` 时抛 `AttributeError`。"""
|
||||
|
||||
def download(
|
||||
url: str,
|
||||
header_map: dict[str, str],
|
||||
file_name: str,
|
||||
segment: _SegmentArg,
|
||||
proxy: Optional[str] = None,
|
||||
) -> None:
|
||||
"""下载 FLV 或 HLS 直播流。
|
||||
|
||||
def upload(video_path: List[str],
|
||||
cookie_file: str,
|
||||
title: str,
|
||||
tid: int,
|
||||
tag: str,
|
||||
copyright: int,
|
||||
source: str,
|
||||
desc: str,
|
||||
dynamic: str,
|
||||
cover: str,
|
||||
dolby: int,
|
||||
lossless_music: int,
|
||||
no_reprint: int,
|
||||
charging_pay: int,
|
||||
limit: int,
|
||||
desc_v2: List[Credit],
|
||||
dtime: Optional[int],
|
||||
line: Optional[UploadLine],
|
||||
extra_fields: Optional[str],
|
||||
submit: Optional[str],
|
||||
proxy: Optional[str]) -> None:
|
||||
|
||||
:param url: 流地址
|
||||
:param header_map: HTTP 请求头
|
||||
:param file_name: 文件名格式,不含扩展名,支持 strftime 占位符(如 `%Y-%m-%d`)
|
||||
:param segment: 分段设置:`PySegment`、带 `time` / `size` 键的 dict,或带这两个属性的对象
|
||||
:param proxy: 代理
|
||||
:raises StreamGearsError: 连接失败、HTTP 错误状态、流数据不完整或解析失败
|
||||
"""
|
||||
上传视频稿件
|
||||
|
||||
:param List[str] video_path: 视频文件路径
|
||||
:param str cookie_file: cookie文件路径
|
||||
:param str title: 视频标题
|
||||
:param int tid: 投稿分区
|
||||
:param str tag: 视频标签, 英文逗号分隔多个tag
|
||||
:param int copyright: 是否转载, 1-自制 2-转载
|
||||
:param str source: 转载来源
|
||||
:param str desc: 视频简介
|
||||
:param str dynamic: 空间动态
|
||||
:param str cover: 视频封面
|
||||
:param int dolby: 是否开启杜比音效, 0-关闭 1-开启
|
||||
:param int lossless_music: 是否开启Hi-Res, 0-关闭 1-开启
|
||||
:param int no_reprint: 是否禁止转载, 0-允许 1-禁止
|
||||
:param int charging_pay: 是否开启充电, 0-关闭 1-开启
|
||||
:param int limit: 单视频文件最大并发数
|
||||
:param List[Credit] desc_v2: 视频简介v2
|
||||
:param Optional[dtime] int dtime: 定时发布时间, 距离提交大于2小时小于15天, 格式为10位时间戳
|
||||
:param Optional[UploadLine] line: 上传线路
|
||||
:param Optional[ExtraFields] line: 上传额外参数
|
||||
:param Optional[str] submit: 提交接口, 可选值: BCutAndroid, App(默认)
|
||||
:param Optional[str] proxy: 代理
|
||||
def download_with_callback(
|
||||
url: str,
|
||||
header_map: dict[str, str],
|
||||
file_name: str,
|
||||
segment: _SegmentArg,
|
||||
file_name_callback_fn: Optional[Callable[[str], object]] = None,
|
||||
proxy: Optional[str] = None,
|
||||
) -> None:
|
||||
"""下载 FLV 或 HLS 直播流,每个分段文件写完时调用回调。
|
||||
|
||||
回调抛出的异常会立即记进日志,下载继续;下载结束后重新抛出第一个回调异常。
|
||||
下载本身也失败时抛 `StreamGearsError`,回调异常挂在它的 `__cause__` 上。
|
||||
|
||||
:param file_name_callback_fn: 以写完的文件名为参数的回调
|
||||
:raises StreamGearsError: 同 `download`
|
||||
:raises TypeError: `file_name_callback_fn` 不可调用
|
||||
"""
|
||||
|
||||
def login_by_cookies(file: str, proxy: Optional[str] = None) -> bool:
|
||||
"""用 cookie 文件登录。
|
||||
|
||||
:param file: cookie 文件路径
|
||||
:return: 成功时为 `True`
|
||||
:raises StreamGearsError: 登录失败
|
||||
"""
|
||||
|
||||
def send_sms(country_code: int, phone: int, proxy: Optional[str] = None) -> str:
|
||||
"""发送短信验证码。
|
||||
|
||||
:return: 传给 `login_by_sms` 的 JSON 字符串
|
||||
"""
|
||||
|
||||
def login_by_sms(
|
||||
code: int,
|
||||
ret: str,
|
||||
proxy: Optional[str] = None,
|
||||
file: _StrPath = "cookies.json",
|
||||
) -> bool:
|
||||
"""短信验证码登录,成功后把登录信息写入 `file`。
|
||||
|
||||
:param code: 验证码
|
||||
:param ret: `send_sms` 的返回值
|
||||
:return: 成功时为 `True`
|
||||
:raises StreamGearsError: 登录失败
|
||||
:raises ValueError: `ret` 不是合法 JSON
|
||||
"""
|
||||
|
||||
def get_qrcode(proxy: Optional[str] = None) -> str:
|
||||
"""获取登录二维码。
|
||||
|
||||
:return: 传给 `login_by_qrcode` 的 JSON 字符串
|
||||
"""
|
||||
|
||||
def login_by_qrcode(ret: str, proxy: Optional[str] = None, file: Optional[_StrPath] = None) -> str:
|
||||
"""等待扫码登录。
|
||||
|
||||
:param ret: `get_qrcode` 的返回值
|
||||
:param file: 给了就同时把登录信息写入该文件
|
||||
:return: 登录信息的 JSON 字符串
|
||||
:raises StreamGearsError: 登录失败
|
||||
:raises ValueError: `ret` 不是合法 JSON
|
||||
"""
|
||||
|
||||
def login_by_web_cookies(
|
||||
sess_data: str,
|
||||
bili_jct: str,
|
||||
proxy: Optional[str] = None,
|
||||
file: _StrPath = "cookies.json",
|
||||
) -> bool:
|
||||
"""用网页 Cookie(`SESSDATA`、`bili_jct`)登录,成功后把登录信息写入 `file`。
|
||||
|
||||
:return: 成功时为 `True`
|
||||
:raises StreamGearsError: 登录失败
|
||||
"""
|
||||
|
||||
def login_by_web_qrcode(
|
||||
sess_data: str,
|
||||
dede_user_id: str,
|
||||
proxy: Optional[str] = None,
|
||||
file: _StrPath = "cookies.json",
|
||||
) -> bool:
|
||||
"""用网页 Cookie(`SESSDATA`、`DedeUserID`)登录,成功后把登录信息写入 `file`。
|
||||
|
||||
:return: 成功时为 `True`
|
||||
:raises StreamGearsError: 登录失败
|
||||
"""
|
||||
|
||||
def upload(
|
||||
video_path: Sequence[_StrPath],
|
||||
cookie_file: _StrPath,
|
||||
title: str,
|
||||
tid: int = 171,
|
||||
tid_v2: Optional[int] = None,
|
||||
tag: str = "",
|
||||
copyright: int = 2,
|
||||
source: str = "",
|
||||
desc: str = "",
|
||||
dynamic: str = "",
|
||||
cover: str = "",
|
||||
dolby: int = 0,
|
||||
lossless_music: int = 0,
|
||||
no_reprint: int = 0,
|
||||
charging_pay: int = 0,
|
||||
up_close_reply: bool = False,
|
||||
up_selection_reply: bool = False,
|
||||
up_close_danmu: bool = False,
|
||||
limit: int = 3,
|
||||
desc_v2: Sequence[Union[Credit, _CreditObject]] = [],
|
||||
dtime: Optional[int] = None,
|
||||
line: Optional[UploadLine] = None,
|
||||
extra_fields: Optional[str] = "",
|
||||
submit: Optional[str] = None,
|
||||
proxy: Optional[str] = None,
|
||||
) -> None:
|
||||
"""上传视频并投稿。
|
||||
|
||||
:param video_path: 视频文件路径,每个文件是一个分P
|
||||
:param cookie_file: cookie 文件路径
|
||||
:param title: 标题
|
||||
:param tid: 分区
|
||||
:param tid_v2: 新版分区
|
||||
:param tag: 标签,英文逗号分隔
|
||||
:param copyright: 1 自制,2 转载
|
||||
:param source: 转载来源
|
||||
:param desc: 简介
|
||||
:param dynamic: 空间动态
|
||||
:param cover: 封面图片路径,空串表示不设置
|
||||
:param dolby: 杜比音效,0 关闭,1 开启
|
||||
:param lossless_music: Hi-Res 无损音质,0 关闭,1 开启
|
||||
:param no_reprint: 禁止转载,0 允许,1 禁止
|
||||
:param charging_pay: 充电专属,0 关闭,1 开启
|
||||
:param up_close_reply: 关闭评论
|
||||
:param up_selection_reply: 开启精选评论
|
||||
:param up_close_danmu: 关闭弹幕
|
||||
:param limit: 单个文件的并发上传数
|
||||
:param desc_v2: 带 @ 的简介,元素见 `Credit`;也接受带 `type_id` / `raw_text` / `biz_id` 属性的对象
|
||||
:param dtime: 定时发布的 10 位时间戳,须在提交后 2 小时到 15 天之间
|
||||
:param line: 上传线路,`None` 时自动测速
|
||||
:param extra_fields: 追加到投稿请求里的 JSON 对象字符串;`None` 或空串表示没有
|
||||
:param submit: 投稿接口:`app`(默认)、`web`、`bcutandroid`,不区分大小写,无法识别时用 `app`
|
||||
:param proxy: 代理
|
||||
:raises StreamGearsError: 登录、上传或投稿失败
|
||||
:raises ValueError: `extra_fields` 不是合法的 JSON 对象
|
||||
:raises TypeError: `desc_v2` 元素缺少必填字段
|
||||
"""
|
||||
|
||||
@deprecated("config_bindings() always returns the built-in default configuration and will be removed.")
|
||||
def config_bindings() -> _ConfigState:
|
||||
"""总是返回内置的默认配置;运行中的服务不读写它。"""
|
||||
|
||||
def main_loop() -> None:
|
||||
"""`biliup` 命令行入口,参数取自 `sys.argv`。无参数或 `start` 时按 `server` 运行。
|
||||
|
||||
:raises SystemExit: 命令行参数错误
|
||||
:raises RuntimeError: 服务运行出错
|
||||
"""
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Layout and type stubs of the installed ``stream_gears`` package.
|
||||
|
||||
Run against an installed wheel: ``pytest crates/stream-gears/tests``.
|
||||
"""
|
||||
|
||||
import importlib.machinery
|
||||
import importlib.metadata
|
||||
import inspect
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import stream_gears
|
||||
from stream_gears import Credit, Segment, StreamGearsError, UploadLine
|
||||
|
||||
PACKAGE_FILES = {
|
||||
"stream_gears/__init__.py",
|
||||
"stream_gears/py.typed",
|
||||
"stream_gears/stream_gears.pyi",
|
||||
}
|
||||
|
||||
|
||||
def test_top_level_names():
|
||||
assert Segment is stream_gears.PySegment
|
||||
assert UploadLine is stream_gears.UploadLine
|
||||
assert issubclass(StreamGearsError, RuntimeError)
|
||||
assert Credit(type=1, raw_text="hi") == {"type": 1, "raw_text": "hi"}
|
||||
assert Credit.__required_keys__ == {"type", "raw_text"}
|
||||
assert Credit.__optional_keys__ == {"biz_id"}
|
||||
|
||||
|
||||
def test_native_module_is_a_submodule():
|
||||
native = stream_gears.stream_gears
|
||||
assert native.__name__ == "stream_gears.stream_gears"
|
||||
assert native.__file__.endswith(tuple(importlib.machinery.EXTENSION_SUFFIXES))
|
||||
assert Path(native.__file__).parent == Path(stream_gears.__file__).parent
|
||||
|
||||
|
||||
def test_all_matches_native_module():
|
||||
assert set(stream_gears.__all__) == set(stream_gears.stream_gears.__all__) | {
|
||||
"Credit",
|
||||
"Segment",
|
||||
}
|
||||
|
||||
|
||||
def test_wheel_ships_stubs():
|
||||
files = {str(f).replace("\\", "/") for f in importlib.metadata.files("biliup")}
|
||||
assert PACKAGE_FILES <= files
|
||||
package_dir = Path(stream_gears.__file__).parent
|
||||
assert (package_dir / "py.typed").is_file()
|
||||
assert (package_dir / "stream_gears.pyi").is_file()
|
||||
|
||||
|
||||
def test_upload_text_signature_is_accepted():
|
||||
"""`upload` has a hand-written `text_signature`; every name and default in it
|
||||
must go through PyO3's argument parsing. An invalid `extra_fields` raises
|
||||
ValueError before anything else happens; a wrong name would raise TypeError."""
|
||||
params = inspect.signature(stream_gears.upload).parameters
|
||||
required = {"video_path": ["a.mp4"], "cookie_file": "cookies.json", "title": "t"}
|
||||
kwargs = {
|
||||
name: required[name] if p.default is inspect.Parameter.empty else p.default
|
||||
for name, p in params.items()
|
||||
}
|
||||
assert kwargs["tag"] == "" and kwargs["desc_v2"] == [] and kwargs["tid"] == 171
|
||||
kwargs["extra_fields"] = "{"
|
||||
with pytest.raises(ValueError, match="extra_fields"):
|
||||
stream_gears.upload(**kwargs)
|
||||
|
||||
|
||||
GOOD = """
|
||||
import stream_gears
|
||||
from stream_gears import Credit, Segment, StreamGearsError, UploadLine
|
||||
|
||||
segment = Segment()
|
||||
segment.time = 60
|
||||
credit: Credit = {"type": 2, "raw_text": "name", "biz_id": "1"}
|
||||
qrcode: str = stream_gears.login_by_qrcode(stream_gears.get_qrcode())
|
||||
ok: bool = stream_gears.login_by_cookies("cookies.json")
|
||||
|
||||
try:
|
||||
stream_gears.download("http://x/live.flv", {}, "out", {"time": 60})
|
||||
stream_gears.download_with_callback("http://x/live.flv", {}, "out", segment, print)
|
||||
stream_gears.upload(
|
||||
["a.mp4"], "cookies.json", "title", desc_v2=[credit], line=UploadLine.Cnbldsa
|
||||
)
|
||||
except StreamGearsError as e:
|
||||
print(e)
|
||||
"""
|
||||
|
||||
BAD = """
|
||||
import stream_gears
|
||||
|
||||
a: bool = stream_gears.login_by_qrcode("{}")
|
||||
stream_gears.upload(["a.mp4"], "cookies.json", title=1)
|
||||
stream_gears.UploadLine.Nope
|
||||
stream_gears.download("http://x/live.flv", {}, "out", 60)
|
||||
stream_gears.upload(["a.mp4"], "cookies.json", "t", desc_v2=[{"raw_text": "x"}])
|
||||
"""
|
||||
|
||||
|
||||
def run_mypy(source, tmp_path):
|
||||
pytest.importorskip("mypy")
|
||||
(tmp_path / "example.py").write_text(textwrap.dedent(source))
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "mypy", "--strict", "--no-incremental", "example.py"],
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
|
||||
def test_stubs_accept_valid_usage(tmp_path):
|
||||
result = run_mypy(GOOD, tmp_path)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
def test_stubs_reject_invalid_usage(tmp_path):
|
||||
result = run_mypy(BAD, tmp_path)
|
||||
assert result.returncode == 1, result.stdout + result.stderr
|
||||
lines = {
|
||||
int(line.split(":")[1])
|
||||
for line in result.stdout.splitlines()
|
||||
if ": error:" in line
|
||||
}
|
||||
assert lines == {4, 5, 6, 7, 8}, result.stdout
|
||||
@@ -14,7 +14,9 @@ import pytest
|
||||
import stream_gears
|
||||
|
||||
EXPORTS = {
|
||||
"Credit",
|
||||
"PySegment",
|
||||
"Segment",
|
||||
"StreamGearsError",
|
||||
"UploadLine",
|
||||
"config_bindings",
|
||||
|
||||
+7
-1
@@ -8,7 +8,13 @@ features = ["pyo3/extension-module"]
|
||||
# Include additional files
|
||||
#include = [ "biliup/web/public/**/*" ]
|
||||
#data = "biliup/web/public"
|
||||
python-packages = ["biliup"]
|
||||
# Mixed layout: `crates/stream-gears/stream_gears/` (`__init__.py`, stubs, `py.typed`)
|
||||
# ships alongside the native module, which becomes `stream_gears.stream_gears`.
|
||||
python-source = "crates/stream-gears"
|
||||
module-name = "stream_gears.stream_gears"
|
||||
# `python-packages` is resolved under `python-source`, so the root `biliup/`
|
||||
# package goes in through `include` instead.
|
||||
include = [{ path = "biliup/**/*.py", format = ["sdist", "wheel"] }]
|
||||
manifest-path = "crates/stream-gears/Cargo.toml"
|
||||
|
||||
[project]
|
||||
|
||||
Reference in new issue
Block a user