From 753aade9f40bad06c8071e117ceb198399102c42 Mon Sep 17 00:00:00 2001 From: Noah Date: Thu, 24 Sep 2026 03:58:52 +0000 Subject: [PATCH] refactor(cli): single entry for native binary and main_loop (#1728) * refactor(cli): single entry for native binary and main_loop Move argument parsing, logging setup and command dispatch from biliup-cli's main.rs into `biliup_cli::entry`, and make stream_gears.main_loop call the same entry instead of its own copy. - Both entries default to `server` when no subcommand is given (or the legacy `start`), expand `~` in paths, and log to `ds_update.log` (server only, no rotation) instead of `download.log` on the wheel. - The log filter honours `RUST_LOG`; an explicit `--rust-log` overrides it. - main_loop no longer calls process::exit on clap errors: `--help` and `--version` print and return, other parse errors raise SystemExit with clap's exit code. Logging uses try_init, so a second call in the same process does not panic. - Export `entry::serve(ServeOptions)` so a host can run the Web server on its own tokio runtime. - Document config_bindings as deprecated (behaviour unchanged). Ref #1721 * test(stream-gears): cover the shared entry from the installed wheel Exit codes of --help/--version/--bad-flag, main_loop called twice in one process, and biliup server writing ds_update.log with RUST_LOG and --rust-log precedence. Ref #1721 * test(stream-gears): update smoke test comment now that --help no longer exits * build(stream-gears): drop clap, unused after the shared entry * ci(docker): start the built image and check GET / returns 200 The test job only built the image. Also run it with the default CMD and require the Web UI to answer and ds_update.log to be written. Ref #1721 --- .github/workflows/docker-publish.yml | 16 ++ Cargo.lock | 1 - crates/biliup-cli/src/cli.rs | 6 +- crates/biliup-cli/src/entry.rs | 362 ++++++++++++++++++++++++ crates/biliup-cli/src/lib.rs | 3 +- crates/biliup-cli/src/main.rs | 209 +------------- crates/stream-gears/Cargo.toml | 1 - crates/stream-gears/src/lib.rs | 38 ++- crates/stream-gears/src/server.rs | 194 +------------ crates/stream-gears/tests/test_entry.py | 154 ++++++++++ crates/stream-gears/tests/test_smoke.py | 6 +- 11 files changed, 566 insertions(+), 424 deletions(-) create mode 100644 crates/biliup-cli/src/entry.rs create mode 100644 crates/stream-gears/tests/test_entry.py diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 5add8ac..d53b73a 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -55,9 +55,25 @@ jobs: docker build \ --build-arg repo_url=$REPO_URL \ --build-arg branch_name=$BRANCH_NAME \ + --tag biliup-test \ . --file Dockerfile fi + - name: Smoke test container + if: hashFiles('docker-compose.test.yml') == '' + run: | + docker run -d --name biliup-test -p 19159:19159 biliup-test + for i in $(seq 1 30); do + if curl -fsS -o /dev/null http://127.0.0.1:19159/; then + docker exec biliup-test test -s /opt/ds_update.log + docker rm -f biliup-test + exit 0 + fi + sleep 2 + done + docker logs biliup-test + exit 1 + build: needs: test diff --git a/Cargo.lock b/Cargo.lock index 8370382..c415880 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4417,7 +4417,6 @@ dependencies = [ "biliup", "biliup-cli", "bon", - "clap", "danmaku", "error-stack", "nom 8.0.0", diff --git a/crates/biliup-cli/src/cli.rs b/crates/biliup-cli/src/cli.rs index e9cc0e4..98c6b63 100644 --- a/crates/biliup-cli/src/cli.rs +++ b/crates/biliup-cli/src/cli.rs @@ -31,9 +31,9 @@ pub struct Cli { #[arg(short, long, default_value = "cookies.json")] pub user_cookie: PathBuf, - // #[arg(long, default_value = "sqlx=debug,tower_http=debug,info")] - #[arg(long, default_value = "tower_http=debug,info")] - pub rust_log: String, + /// 日志过滤规则,如 debug;不指定时读取环境变量 RUST_LOG,都没有则为 tower_http=debug,info + #[arg(long)] + pub rust_log: Option, } #[derive(Subcommand)] diff --git a/crates/biliup-cli/src/entry.rs b/crates/biliup-cli/src/entry.rs new file mode 100644 index 0000000..d2695e1 --- /dev/null +++ b/crates/biliup-cli/src/entry.rs @@ -0,0 +1,362 @@ +//! Shared entry point for the native `biliup` binary and the Python +//! `stream_gears.main_loop` wrapper, so both parse, log and dispatch the same way. + +use std::ffi::OsString; +use std::path::PathBuf; +use std::sync::OnceLock; + +use biliup::uploader::util::SubmitOption; +use clap::Parser; +use error_stack::ResultExt; +use time::macros::format_description; +use tracing_appender::non_blocking::WorkerGuard; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::{EnvFilter, reload}; + +use crate::LogHandle; +use crate::cli::{Cli, Commands, expand_path}; +use crate::downloader::{download, generate_json}; +use crate::server::errors::{AppError, AppResult}; +use crate::uploader::{ + append, comments, list, login, renew, reply, show, upload_by_command, upload_by_config, +}; + +pub const DEFAULT_LOG_FILTER: &str = "tower_http=debug,info"; +pub const LOG_FILE: &str = "ds_update.log"; + +static LOG_HANDLE: OnceLock = OnceLock::new(); + +/// Fills in the default subcommand: no subcommand, or the legacy `start`, means `server`. +pub fn with_default_command(args: I) -> Vec +where + I: IntoIterator, + T: Into, +{ + let mut args: Vec = args.into_iter().map(Into::into).collect(); + match args.len() { + 0 => args.extend(["biliup".into(), "server".into()]), + 1 => args.push("server".into()), + _ if args[1] == "start" => args[1] = "server".into(), + _ => {} + } + args +} + +/// Parses command-line arguments (including `argv[0]`) after applying [`with_default_command`]. +pub fn parse(args: I) -> Result +where + I: IntoIterator, + T: Into, +{ + Cli::try_parse_from(with_default_command(args)) +} + +/// An explicit `--rust-log` wins over `RUST_LOG`, which wins over [`DEFAULT_LOG_FILTER`]. +pub fn log_filter_directives(cli_value: Option<&str>, env_value: Option<&str>) -> String { + cli_value + .or(env_value.filter(|value| !value.trim().is_empty())) + .unwrap_or(DEFAULT_LOG_FILTER) + .to_owned() +} + +pub fn writes_log_file(command: &Commands) -> bool { + matches!(command, Commands::Server { .. }) +} + +/// Keeps the log file writer alive; drop it only after the command has finished. +pub struct Logging { + handle: LogHandle, + _file_guard: Option, +} + +impl Logging { + pub fn handle(&self) -> LogHandle { + self.handle.clone() + } +} + +/// Initialises logging for `cli`: console output, plus `ds_update.log` in the +/// working directory for the `server` command. +pub fn init_tracing(cli: &Cli) -> Logging { + let env = std::env::var("RUST_LOG").ok(); + init_tracing_with( + &log_filter_directives(cli.rust_log.as_deref(), env.as_deref()), + writes_log_file(&cli.command), + ) +} + +/// Installs the global subscriber on the first call. Later calls in the same +/// process (e.g. `main_loop` called twice) keep the existing layers and only +/// swap the filter. +pub fn init_tracing_with(filter: &str, log_file: bool) -> Logging { + if let Some(handle) = LOG_HANDLE.get() { + let _ = handle.modify(|current| *current = EnvFilter::new(filter)); + return Logging { + handle: handle.clone(), + _file_guard: None, + }; + } + + let timer = tracing_subscriber::fmt::time::LocalTime::new(format_description!( + "[year]-[month]-[day] [hour]:[minute]:[second]" + )); + let (filter_layer, handle) = reload::Layer::new(EnvFilter::new(filter)); + + let (file_layer, file_guard) = if log_file { + let file_appender = tracing_appender::rolling::never(".", LOG_FILE); + let (file_writer, file_guard) = tracing_appender::non_blocking(file_appender); + let file_layer = tracing_subscriber::fmt::layer() + .with_timer(timer.clone()) + .with_writer(file_writer) + .with_ansi(false); + (Some(file_layer), Some(file_guard)) + } else { + (None, None) + }; + + let installed = tracing_subscriber::registry() + .with(filter_layer) + .with(tracing_subscriber::fmt::layer().with_timer(timer)) + .with(file_layer) + .try_init() + .is_ok(); + if installed { + let _ = LOG_HANDLE.set(handle.clone()); + } + Logging { + handle, + _file_guard: file_guard, + } +} + +/// Initialises logging and runs the command described by `cli`. +pub async fn run(cli: Cli) -> AppResult<()> { + let logging = init_tracing(&cli); + dispatch(cli, logging.handle()).await +} + +/// Runs the command described by `cli`. Logging must already be initialised; +/// `log_handle` lets the Web UI change the log level at runtime. +pub async fn dispatch(cli: Cli, log_handle: LogHandle) -> AppResult<()> { + let user_cookie = expand_path(cli.user_cookie); + let proxy = cli.proxy.as_deref(); + + match cli.command { + Commands::Login => login(user_cookie, proxy).await?, + Commands::Renew => renew(user_cookie, proxy).await?, + Commands::Upload { + video_path, + config: None, + line, + limit, + studio, + submit, + } => { + let video_path: Vec<_> = video_path.into_iter().map(expand_path).collect(); + upload_by_command( + studio, + user_cookie, + video_path, + line, + limit, + submit.unwrap_or(SubmitOption::App), + proxy, + ) + .await? + } + Commands::Upload { + video_path: _, + config: Some(config), + submit, + .. + } => upload_by_config(expand_path(config), user_cookie, submit, proxy).await?, + Commands::Append { + video_path, + vid, + line, + limit, + studio: _, + submit, + } => { + let video_path: Vec<_> = video_path.into_iter().map(expand_path).collect(); + append( + user_cookie, + vid, + video_path, + line, + limit, + submit.unwrap_or(SubmitOption::App), + proxy, + ) + .await? + } + Commands::Show { vid } => show(user_cookie, vid, proxy).await?, + Commands::Comments { vid, sort, pn, ps } => { + comments(user_cookie, vid, sort, pn, ps, proxy).await? + } + Commands::Reply { + vid, + rpid, + message, + execute, + } => reply(user_cookie, vid, rpid, message, execute, proxy).await?, + Commands::DumpFlv { file_name } => generate_json(expand_path(file_name))?, + Commands::Download { + url, + output, + split_size, + split_time, + } => download(&url, output, split_size, split_time).await?, + Commands::Server { + bind, + port, + auth, + secure_session_cookie, + config, + } => { + serve(ServeOptions { + bind, + port, + auth, + secure_session_cookie, + config, + user_cookie, + work_dir: None, + log_handle, + }) + .await? + } + Commands::User { action } => crate::web_user_cli::run(action).await?, + Commands::List { + is_pubing, + pubed, + not_pubed, + from_page, + max_pages, + } => { + list( + user_cookie, + is_pubing, + pubed, + not_pubed, + proxy, + from_page, + max_pages, + ) + .await? + } + }; + Ok(()) +} + +/// Options for [`serve`]; the fields mirror `biliup server`. +pub struct ServeOptions { + pub bind: String, + pub port: u16, + pub auth: bool, + pub secure_session_cookie: bool, + pub config: Option, + pub user_cookie: PathBuf, + /// The server keeps `data/`, `ds_update.log` and recordings relative to the + /// working directory. When set, the directory is created and the whole + /// process switches to it before the server starts. + pub work_dir: Option, + pub log_handle: LogHandle, +} + +/// Runs the Web server until shutdown. It only needs a tokio runtime, so an +/// embedding host (e.g. the desktop app) can spawn it on its own runtime after +/// calling [`init_tracing_with`]. +pub async fn serve(opts: ServeOptions) -> AppResult<()> { + if let Some(dir) = &opts.work_dir { + std::fs::create_dir_all(dir) + .and_then(|()| std::env::set_current_dir(dir)) + .change_context(AppError::Unknown) + .attach_with(|| format!("could not switch to work dir {}", dir.display()))?; + } + crate::run_with_cookie( + (&opts.bind, opts.port), + opts.auth, + opts.secure_session_cookie, + opts.log_handle, + opts.config, + opts.user_cookie, + ) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + + fn normalized(args: &[&str]) -> Vec { + with_default_command(args.iter().copied()) + .into_iter() + .map(|arg| arg.into_string().unwrap()) + .collect() + } + + #[test] + fn default_command_is_server() { + assert_eq!(normalized(&[]), ["biliup", "server"]); + assert_eq!(normalized(&["biliup"]), ["biliup", "server"]); + assert_eq!( + normalized(&["biliup", "start", "--port", "1"]), + ["biliup", "server", "--port", "1"] + ); + assert_eq!(normalized(&["biliup", "renew"]), ["biliup", "renew"]); + assert_eq!(normalized(&["biliup", "--help"]), ["biliup", "--help"]); + assert_eq!( + normalized(&["biliup", "-u", "start"]), + ["biliup", "-u", "start"] + ); + } + + #[test] + fn parse_without_arguments_starts_server() { + let cli = parse(["biliup"]).unwrap(); + assert!(matches!(cli.command, Commands::Server { port: 19159, .. })); + assert!(cli.rust_log.is_none()); + let cli = parse(["biliup", "start", "--port", "0"]).unwrap(); + assert!(matches!(cli.command, Commands::Server { port: 0, .. })); + } + + #[test] + fn help_and_version_are_errors_with_zero_exit_code() { + for flag in ["--help", "--version"] { + let err = parse(["biliup", flag]).err().unwrap(); + assert_eq!(err.exit_code(), 0, "{flag}"); + } + let err = parse(["biliup", "--bad-flag"]).err().unwrap(); + assert_ne!(err.exit_code(), 0); + } + + #[test] + fn explicit_rust_log_overrides_env() { + let cli = parse(["biliup", "--rust-log", "debug", "server"]).unwrap(); + assert_eq!(cli.rust_log.as_deref(), Some("debug")); + assert_eq!( + log_filter_directives(cli.rust_log.as_deref(), Some("warn")), + "debug" + ); + } + + #[test] + fn env_rust_log_applies_without_flag() { + assert_eq!(log_filter_directives(None, Some("warn")), "warn"); + assert_eq!(log_filter_directives(None, Some(" ")), DEFAULT_LOG_FILTER); + assert_eq!(log_filter_directives(None, None), DEFAULT_LOG_FILTER); + } + + #[test] + fn log_file_is_written_only_by_the_server_command() { + let server = parse(["biliup"]).unwrap(); + let renew = parse(["biliup", "renew"]).unwrap(); + let list = parse(["biliup", "list"]).unwrap(); + + assert!(writes_log_file(&server.command)); + assert!(!writes_log_file(&renew.command)); + assert!(!writes_log_file(&list.command)); + } +} diff --git a/crates/biliup-cli/src/lib.rs b/crates/biliup-cli/src/lib.rs index 6f42c25..0b3fa40 100644 --- a/crates/biliup-cli/src/lib.rs +++ b/crates/biliup-cli/src/lib.rs @@ -1,5 +1,6 @@ pub mod cli; pub mod downloader; +pub mod entry; pub mod server; pub mod upload_lock; pub mod uploader; @@ -27,7 +28,7 @@ use tracing_subscriber::{EnvFilter, Registry, reload}; // 定义 Handle 的类型别名,简化代码 // EnvFilter: 我们使用的过滤器类型 // Registry: 基础的 Subscriber 类型 -type LogHandle = reload::Handle; +pub type LogHandle = reload::Handle; pub async fn run( addr: (&str, u16), diff --git a/crates/biliup-cli/src/main.rs b/crates/biliup-cli/src/main.rs index 6f75c50..8972529 100644 --- a/crates/biliup-cli/src/main.rs +++ b/crates/biliup-cli/src/main.rs @@ -1,211 +1,8 @@ -use time::macros::format_description; - -use biliup::uploader::util::SubmitOption; -use biliup_cli::cli::{Cli, Commands, expand_path}; -use biliup_cli::downloader::{download, generate_json}; -use biliup_cli::uploader::{ - append, comments, list, login, renew, reply, show, upload_by_command, upload_by_config, -}; - -use clap::Parser; - +use biliup_cli::entry; use biliup_cli::server::errors::AppResult; -use tracing_subscriber::layer::SubscriberExt; -use tracing_subscriber::reload; -use tracing_subscriber::util::SubscriberInitExt; - -fn writes_web_log(command: &Commands) -> bool { - matches!(command, Commands::Server { .. }) -} #[tokio::main] async fn main() -> AppResult<()> { - // a builder for `FmtSubscriber`. - // let subscriber = FmtSubscriber::builder() - // // all spans/events with a level higher than TRACE (e.g, debug, info, warn, etc.) - // // will be written to stdout. - // .with_max_level(Level::INFO) - // // completes the builder. - // .finish(); - - // tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); - let cli = Cli::parse(); - - // use of deprecated function `time::util::local_offset::set_soundness`: no longer needed; TZ is refreshed manually - // unsafe { - // time::util::local_offset::set_soundness(time::util::local_offset::Soundness::Unsound); - // } - - let timer = tracing_subscriber::fmt::time::LocalTime::new(format_description!( - "[year]-[month]-[day] [hour]:[minute]:[second]" - )); - - let console_filter = tracing_subscriber::EnvFilter::new(&cli.rust_log); - // let (file_filter_layer, file_reload_handle) = reload::Layer::new(file_filter); - let (console_filter_layer, console_reload_handle) = reload::Layer::new(console_filter); - - // Only the Web server owns `ds_update.log`. Short-lived credential - // commands still log to the console, but do not persist their output. - let (file_layer, file_guard) = if writes_web_log(&cli.command) { - let file_appender = tracing_appender::rolling::never(".", "ds_update.log"); - let (file_writer, file_guard) = tracing_appender::non_blocking(file_appender); - let file_layer = tracing_subscriber::fmt::layer() - .with_timer(timer.clone()) - .with_writer(file_writer) - .with_ansi(false); - (Some(file_layer), Some(file_guard)) - } else { - (None, None) - }; - - tracing_subscriber::registry() - .with(console_filter_layer) - .with(tracing_subscriber::fmt::layer().with_timer(timer)) - .with(file_layer) - .init(); - - let user_cookie = expand_path(cli.user_cookie); - - match cli.command { - Commands::Login => login(user_cookie, cli.proxy.as_deref()).await?, - Commands::Renew => { - renew(user_cookie, cli.proxy.as_deref()).await?; - } - Commands::Upload { - video_path, - config: None, - line, - limit, - studio, - submit, - } => { - let video_path: Vec<_> = video_path.into_iter().map(expand_path).collect(); - upload_by_command( - studio, - user_cookie, - video_path, - line, - limit, - submit.unwrap_or(SubmitOption::App), - cli.proxy.as_deref(), - ) - .await? - } - Commands::Upload { - video_path: _, - config: Some(config), - submit, - .. - } => { - let config = expand_path(config); - upload_by_config(config, user_cookie, submit, cli.proxy.as_deref()).await?; - } - Commands::Append { - video_path, - vid, - line, - limit, - studio: _, - submit, - } => { - let video_path: Vec<_> = video_path.into_iter().map(expand_path).collect(); - append( - user_cookie, - vid, - video_path, - line, - limit, - submit.unwrap_or(SubmitOption::App), - cli.proxy.as_deref(), - ) - .await? - } - Commands::Show { vid } => show(user_cookie, vid, cli.proxy.as_deref()).await?, - Commands::Comments { vid, sort, pn, ps } => { - comments(user_cookie, vid, sort, pn, ps, cli.proxy.as_deref()).await? - } - Commands::Reply { - vid, - rpid, - message, - execute, - } => { - reply( - user_cookie, - vid, - rpid, - message, - execute, - cli.proxy.as_deref(), - ) - .await? - } - Commands::DumpFlv { file_name } => { - let file_name = expand_path(file_name); - generate_json(file_name)? - } - Commands::Download { - url, - output, - split_size, - split_time, - } => download(&url, output, split_size, split_time).await?, - Commands::Server { - bind, - port, - auth, - secure_session_cookie, - config, - } => { - biliup_cli::run_with_cookie( - (&bind, port), - auth, - secure_session_cookie, - console_reload_handle, - config, - user_cookie, - ) - .await? - } - Commands::User { action } => biliup_cli::web_user_cli::run(action).await?, - Commands::List { - is_pubing, - pubed, - not_pubed, - from_page, - max_pages, - } => { - list( - user_cookie, - is_pubing, - pubed, - not_pubed, - cli.proxy.as_deref(), - from_page, - max_pages, - ) - .await? - } - }; - // Keep the optional server writer alive until queued records are flushed. - drop(file_guard); - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::writes_web_log; - use biliup_cli::cli::Cli; - use clap::Parser; - - #[test] - fn persistent_log_is_enabled_only_for_the_server_command() { - let server = Cli::try_parse_from(["biliup", "server"]).unwrap(); - let renew = Cli::try_parse_from(["biliup", "renew"]).unwrap(); - let list = Cli::try_parse_from(["biliup", "list"]).unwrap(); - - assert!(writes_web_log(&server.command)); - assert!(!writes_web_log(&renew.command)); - assert!(!writes_web_log(&list.command)); - } + let cli = entry::parse(std::env::args_os()).unwrap_or_else(|e| e.exit()); + entry::run(cli).await } diff --git a/crates/stream-gears/Cargo.toml b/crates/stream-gears/Cargo.toml index 1bce45a..1c4bfa4 100644 --- a/crates/stream-gears/Cargo.toml +++ b/crates/stream-gears/Cargo.toml @@ -29,7 +29,6 @@ biliup-cli = { path = "../biliup-cli"} danmaku-client = { package = "danmaku", path = "../danmaku"} time = { workspace = true } bon = { workspace = true } -clap = { version = "4", features = ["derive"] } async-trait = { workspace = true } error-stack = { workspace = true } diff --git a/crates/stream-gears/src/lib.rs b/crates/stream-gears/src/lib.rs index 786818a..698a783 100644 --- a/crates/stream-gears/src/lib.rs +++ b/crates/stream-gears/src/lib.rs @@ -21,7 +21,7 @@ use biliup::client::StatelessClient; use biliup::downloader::flv_parser::header; use biliup::downloader::httpflv::Connection; use biliup_cli::server::common::construct_headers; -use pyo3::exceptions::PyRuntimeError; +use pyo3::exceptions::{PyRuntimeError, PySystemExit}; use tracing_subscriber::layer::SubscriberExt; #[tokio::main] @@ -414,32 +414,28 @@ pub fn main_loop(py: Python<'_>) -> PyResult<()> { let bound = sys.getattr("argv")?; let argv: &Bound = bound.cast()?; - let mut args: Vec = argv + let args: Vec = argv .iter() .map(|x| x.extract::()) .collect::>>()?; - // if args.len() == 1 { - // args.push("server".to_string()); - // } - match args.as_slice() { - &[] => { - args.push("biliup".to_string()); - args.push("server".to_string()); + let cli = match biliup_cli::entry::parse(args) { + Ok(cli) => cli, + Err(e) => { + // `--help` / `--version` exit with 0; the console script turns + // SystemExit into the process exit code. + let code = e.exit_code(); + e.print() + .map_err(|err| PyRuntimeError::new_err(err.to_string()))?; + return if code == 0 { + Ok(()) + } else { + Err(PySystemExit::new_err(code)) + }; } - &[_] => { - args.push("server".to_string()); - } - [_, command, ..] => { - if command == "start" { - args[1] = "server".to_string(); - } - } - } + }; - py.detach(|| { - server::_main(args.as_slice()).map_err(|e| PyRuntimeError::new_err(format!("{e:?}"))) - }) + py.detach(|| server::run(cli).map_err(|e| PyRuntimeError::new_err(format!("{e:?}")))) } /// A Python module implemented in Rust. diff --git a/crates/stream-gears/src/server.rs b/crates/stream-gears/src/server.rs index c594ad5..f0b83ab 100644 --- a/crates/stream-gears/src/server.rs +++ b/crates/stream-gears/src/server.rs @@ -1,12 +1,7 @@ -use biliup::uploader::util::SubmitOption; -use biliup_cli::cli::{Cli, Commands}; -use biliup_cli::downloader::generate_json; +use biliup_cli::cli::Cli; +use biliup_cli::entry; use biliup_cli::server::config::Config; use biliup_cli::server::errors::AppResult; -use biliup_cli::uploader::{ - append, comments, list, login, renew, reply, show, upload_by_command, upload_by_config, -}; -use clap::Parser; use pyo3::prelude::PyAnyMethods; use pyo3::prelude::PyDictMethods; use pyo3::types::PyDict; @@ -15,12 +10,6 @@ use pyo3::{pyclass, pyfunction, pymethods}; use pythonize::pythonize; use std::ops::Deref; use std::sync::{Arc, LazyLock, RwLock}; -use time::macros::format_description; -use tracing::info; -use tracing_appender::rolling::Rotation; -use tracing_subscriber::layer::SubscriberExt; -use tracing_subscriber::util::SubscriberInitExt; -use tracing_subscriber::{EnvFilter, reload}; #[pyclass] #[derive(Debug, Clone)] @@ -123,6 +112,8 @@ impl ConfigState { } } +/// Deprecated: always returns the built-in default configuration; the running +/// server never reads or writes it. Kept for compatibility and will be removed. #[pyfunction] pub fn config_bindings() -> PyResult { let state = ConfigState { @@ -144,179 +135,6 @@ fn cfg_arc() -> &'static Arc> { } #[tokio::main] -pub(crate) async fn _main(args: &[String]) -> AppResult<()> { - let cli = match Cli::try_parse_from(args) { - Ok(res) => res, - Err(e) => e.exit(), - }; - - let local_time = tracing_subscriber::fmt::time::LocalTime::new(format_description!( - "[year]-[month]-[day] [hour]:[minute]:[second]" - )); - - let console_layer = tracing_subscriber::fmt::layer() - .with_target(false) - .with_timer(local_time.clone()) - .with_file(true) // 打印文件名 - .with_line_number(true) - .with_thread_ids(true); - - // 按日期滚动,每天创建新文件 - let file_appender = tracing_appender::rolling::RollingFileAppender::builder() - .rotation(Rotation::DAILY) // rotate log files once every hour - .rotation(Rotation::NEVER) // rotate log files once every hour - .filename_prefix("biliup") // log file names will be prefixed with `myapp.` - .filename_prefix("download") // log file names will be prefixed with `myapp.` - .filename_suffix("log") // log file names will be suffixed with `.log` - // .max_log_files(3) - // .build("logs") // try to build an appender that stores log files in `/var/log` - .build("") // try to build an appender that stores log files in `/var/log` - .expect("initializing rolling file appender failed"); - // 或者按小时滚动 - // let file_appender = tracing_appender::rolling::hourly("logs", "upload.log"); - - let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender); - - let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); - let (filter_layer, reload_handle) = reload::Layer::new(filter); - - let file_layer = tracing_subscriber::fmt::layer() - .with_writer(non_blocking) - .with_timer(local_time) - .with_target(true) - .with_thread_ids(true) - .with_file(true) - .with_line_number(true) - .with_ansi(false); - - let subscriber = tracing_subscriber::registry() - .with(filter_layer) // 这个是“总开关”,所有 layer 都会被它过滤 - // 控制台输出 - .with(console_layer) - // 文件输出 - .with( - file_layer, // .json() // 可选:使用 JSON 格式便于解析 - ); - - subscriber.init(); - - info!("Tracing initialized with daily rotation"); - - match cli.command { - Commands::Login => login(cli.user_cookie, cli.proxy.as_deref()).await?, - Commands::Renew => { - renew(cli.user_cookie, cli.proxy.as_deref()).await?; - } - Commands::Upload { - video_path, - config: None, - line, - limit, - studio, - submit, - } => { - upload_by_command( - studio, - cli.user_cookie, - video_path, - line, - limit, - submit.unwrap_or(SubmitOption::App), - cli.proxy.as_deref(), - ) - .await? - } - Commands::Upload { - video_path: _, - config: Some(config), - submit, - .. - } => { - upload_by_config(config, cli.user_cookie, submit, cli.proxy.as_deref()).await?; - } - Commands::Append { - video_path, - vid, - line, - limit, - studio: _, - submit, - } => { - append( - cli.user_cookie, - vid, - video_path, - line, - limit, - submit.unwrap_or(SubmitOption::App), - cli.proxy.as_deref(), - ) - .await? - } - Commands::Show { vid } => show(cli.user_cookie, vid, cli.proxy.as_deref()).await?, - Commands::Comments { vid, sort, pn, ps } => { - comments(cli.user_cookie, vid, sort, pn, ps, cli.proxy.as_deref()).await? - } - Commands::Reply { - vid, - rpid, - message, - execute, - } => { - reply( - cli.user_cookie, - vid, - rpid, - message, - execute, - cli.proxy.as_deref(), - ) - .await? - } - Commands::DumpFlv { file_name } => generate_json(file_name)?, - Commands::Download { - url, - output, - split_size, - split_time, - } => biliup_cli::downloader::download(&url, output, split_size, split_time).await?, - Commands::Server { - bind, - port, - auth, - secure_session_cookie, - config, - } => { - biliup_cli::run_with_cookie( - (&bind, port), - auth, - secure_session_cookie, - reload_handle, - config, - cli.user_cookie, - ) - .await?; - } - Commands::User { action } => biliup_cli::web_user_cli::run(action).await?, - Commands::List { - is_pubing, - pubed, - not_pubed, - from_page, - max_pages, - } => { - list( - cli.user_cookie, - is_pubing, - pubed, - not_pubed, - cli.proxy.as_deref(), - from_page, - max_pages, - ) - .await? - } - }; - - Ok(()) +pub(crate) async fn run(cli: Cli) -> AppResult<()> { + entry::run(cli).await } diff --git a/crates/stream-gears/tests/test_entry.py b/crates/stream-gears/tests/test_entry.py new file mode 100644 index 0000000..fdd36d3 --- /dev/null +++ b/crates/stream-gears/tests/test_entry.py @@ -0,0 +1,154 @@ +"""Entry-point behaviour of the installed wheel (``biliup`` / ``main_loop``). + +Run against an installed wheel, not the source tree: +``pytest crates/stream-gears/tests``. Server tests bind a free loopback port +and run in a temp dir, so they stay offline. +""" + +import os +import signal +import socket +import subprocess +import sys +import sysconfig +import textwrap +import time +import urllib.request +from pathlib import Path + +import pytest + +TIMEOUT = 60 + + +def biliup_script(): + name = "biliup.exe" if sys.platform == "win32" else "biliup" + path = Path(sysconfig.get_path("scripts")) / name + assert path.is_file(), f"console script not installed: {path}" + return str(path) + + +def run(args, cwd, **kwargs): + return subprocess.run( + args, cwd=cwd, capture_output=True, text=True, timeout=TIMEOUT, **kwargs + ) + + +def free_port(): + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def env_without_rust_log(**extra): + env = {k: v for k, v in os.environ.items() if k != "RUST_LOG"} + env.update(extra) + return env + + +def run_server(tmp_path, global_args=(), env=None): + """Starts ``biliup server``, waits for ``GET /`` to return 200, stops it + and returns the contents of ``ds_update.log``.""" + port = free_port() + args = [biliup_script(), *global_args, "server", "--port", str(port)] + proc = subprocess.Popen( + args, + cwd=tmp_path, + env=env or env_without_rust_log(), + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + try: + deadline = time.monotonic() + TIMEOUT + while True: + assert proc.poll() is None, proc.stderr.read() + try: + with urllib.request.urlopen(f"http://127.0.0.1:{port}/", timeout=2) as r: + assert r.status == 200 + break + except OSError: + if time.monotonic() > deadline: + raise + time.sleep(0.5) + finally: + if proc.poll() is None: + proc.send_signal(signal.SIGINT) + try: + proc.wait(timeout=TIMEOUT) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + assert not (tmp_path / "download.log").exists() + return (tmp_path / "ds_update.log").read_text(encoding="utf-8") + + +def debug_lines(log): + """DEBUG records outside tower_http, which is at debug level by default.""" + return [ + line for line in log.splitlines() if " DEBUG " in line and "tower_http" not in line + ] + + +def test_version_exits_zero(tmp_path): + result = run([biliup_script(), "--version"], tmp_path) + assert result.returncode == 0, result.stderr + + +def test_help_exits_zero(tmp_path): + result = run([biliup_script(), "--help"], tmp_path) + assert result.returncode == 0, result.stderr + assert "server" in result.stdout + + +def test_bad_flag_exits_nonzero(tmp_path): + result = run([biliup_script(), "--bad-flag"], tmp_path) + assert result.returncode == 2 + assert "--bad-flag" in result.stderr + + +def test_main_loop_returns_on_help_and_raises_system_exit_on_error(tmp_path): + code = textwrap.dedent( + """ + import sys + import stream_gears + + sys.argv = ["biliup", "--version"] + assert stream_gears.main_loop() is None + assert stream_gears.main_loop() is None + sys.argv = ["biliup", "--help"] + assert stream_gears.main_loop() is None + sys.argv = ["biliup", "--bad-flag"] + try: + stream_gears.main_loop() + except SystemExit as e: + assert e.code == 2, e.code + else: + raise AssertionError("expected SystemExit") + print("still running") + """ + ) + result = run([sys.executable, "-c", code], tmp_path) + assert result.returncode == 0, result.stderr + assert result.stdout.rstrip().endswith("still running") + + +@pytest.mark.skipif(sys.platform == "win32", reason="uses SIGINT to stop the server") +class TestServer: + def test_writes_ds_update_log(self, tmp_path): + log = run_server(tmp_path) + assert "listening on" in log + assert not debug_lines(log) + + def test_rust_log_flag(self, tmp_path): + assert debug_lines(run_server(tmp_path, ["--rust-log", "debug"])) + + def test_rust_log_env(self, tmp_path): + env = env_without_rust_log(RUST_LOG="debug") + assert debug_lines(run_server(tmp_path, env=env)) + + def test_rust_log_flag_overrides_env(self, tmp_path): + env = env_without_rust_log(RUST_LOG="debug") + log = run_server(tmp_path, ["--rust-log", "warn"], env=env) + assert " INFO " not in log + assert not debug_lines(log) diff --git a/crates/stream-gears/tests/test_smoke.py b/crates/stream-gears/tests/test_smoke.py index f067647..7158be7 100644 --- a/crates/stream-gears/tests/test_smoke.py +++ b/crates/stream-gears/tests/test_smoke.py @@ -61,9 +61,9 @@ LOGIN_FUNCTIONS = [ "send_sms", ] -# The CLI exits the process (clap's `--help` / `--version`), so it must run in -# a subprocess. cwd is a temp dir so the repo's `biliup/` source package can't -# shadow the installed one. +# The CLI tests go through the installed console script / `python -m biliup`, +# so they run in a subprocess. cwd is a temp dir so the repo's `biliup/` source +# package can't shadow the installed one. TIMEOUT = 60