diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1202c9c..498fb19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/.gitignore b/.gitignore index 69af76a..bdaa5aa 100644 --- a/.gitignore +++ b/.gitignore @@ -56,4 +56,6 @@ cookies.json .cargo/ logs/ CLAUDE.md -.claude/ \ No newline at end of file +.claude/ +/crates/stream-gears/stream_gears/*.so +/crates/stream-gears/stream_gears/*.pyd diff --git a/Cargo.lock b/Cargo.lock index c415880..d3f69fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/stream-gears/Cargo.toml b/crates/stream-gears/Cargo.toml index 1c4bfa4..014b143 100644 --- a/crates/stream-gears/Cargo.toml +++ b/crates/stream-gears/Cargo.toml @@ -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] diff --git a/crates/stream-gears/src/lib.rs b/crates/stream-gears/src/lib.rs index 9abccc5..b9679d8 100644 --- a/crates/stream-gears/src/lib.rs +++ b/crates/stream-gears/src/lib.rs @@ -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, diff --git a/crates/stream-gears/stream_gears/__init__.py b/crates/stream-gears/stream_gears/__init__.py index ca0fd5b..62145e5 100644 --- a/crates/stream-gears/stream_gears/__init__.py +++ b/crates/stream-gears/stream_gears/__init__.py @@ -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] diff --git a/crates/stream-gears/stream_gears/pyobject.py b/crates/stream-gears/stream_gears/pyobject.py deleted file mode 100644 index 6cbf1fd..0000000 --- a/crates/stream-gears/stream_gears/pyobject.py +++ /dev/null @@ -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] diff --git a/crates/stream-gears/stream_gears/stream_gears.pyi b/crates/stream-gears/stream_gears/stream_gears.pyi index ef1b63f..66c5474 100644 --- a/crates/stream-gears/stream_gears/stream_gears.pyi +++ b/crates/stream-gears/stream_gears/stream_gears.pyi @@ -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: 服务运行出错 """ diff --git a/crates/stream-gears/tests/test_package.py b/crates/stream-gears/tests/test_package.py new file mode 100644 index 0000000..1ef1adc --- /dev/null +++ b/crates/stream-gears/tests/test_package.py @@ -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 diff --git a/crates/stream-gears/tests/test_smoke.py b/crates/stream-gears/tests/test_smoke.py index e939427..856c347 100644 --- a/crates/stream-gears/tests/test_smoke.py +++ b/crates/stream-gears/tests/test_smoke.py @@ -14,7 +14,9 @@ import pytest import stream_gears EXPORTS = { + "Credit", "PySegment", + "Segment", "StreamGearsError", "UploadLine", "config_bindings", diff --git a/pyproject.toml b/pyproject.toml index 81775d4..c2e355a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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]