Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .trellis/spec/backend/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,32 @@ CLI 必须让失败清晰暴露。不要为了让命令“看起来成功”添
- 不使用裸 `except:`。
- 不用 `except Exception: pass`、返回 `None` 或固定成功消息吞掉失败。

## CLI 边界实现约定

Typer 命令函数只捕获项目定义的预期失败类型 `SingCliError`,并在 CLI 边界统一转换为 stderr 输出和非零退出码。命令主体保持主流程代码,不在每个命令里重复写 `try/except SingCliError`。

#### Wrong

```python
@app.command()
def stop() -> None:
try:
stop_service()
except SingCliError as error:
fail(error)
typer.echo("Stopped sing-box service")
```

#### Correct

```python
@app.command()
@report_cli_errors
def stop() -> None:
stop_service()
typer.echo("Stopped sing-box service")
```

## `sing-box.exe` 解析错误

- `sing install` 默认从 `PATH` 解析 `sing-box.exe`。
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{"file": ".trellis/spec/backend/index.md", "reason": "Backend command contracts and quality checklist"}
{"file": ".trellis/spec/backend/error-handling.md", "reason": "Expected CLI failure handling and explicit error boundaries"}
{"file": ".trellis/spec/backend/logging-guidelines.md", "reason": "CLI stdout and stderr output boundaries"}
{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Python quality tools and test expectations"}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{"file": ".trellis/spec/backend/index.md", "reason": "Backend command contracts and pre-development checklist"}
{"file": ".trellis/spec/backend/error-handling.md", "reason": "Expected CLI failure handling and explicit error boundaries"}
{"file": ".trellis/spec/backend/logging-guidelines.md", "reason": "CLI stdout and stderr output boundaries"}
{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Python quality tools and command contracts"}
49 changes: 49 additions & 0 deletions .trellis/tasks/archive/2026-05/05-13-optimize-python-code/prd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Optimize Python code

## Goal

Review the project Python code and apply focused readability and maintainability improvements using `friendly-python` and `piglet` guidance, while preserving existing CLI behavior.

## What I already know

- The project is a Typer-based Python CLI under `src/sing_cli/`.
- The current branch is `main` and the working tree was clean before this task.
- Current backend specs require explicit CLI errors, predictable stdout/stderr, and tests for CLI behavior changes.
- `friendly-python` emphasizes clear boundaries, maintainability, and review against naming, error handling, API shape, and change isolation.
- `piglet` emphasizes single-responsibility functions, stable return types, narrow exception handling, clear naming, and explicit behavior.

## Observed Optimization Candidates

- `src/sing_cli/cli.py` repeats `try/except SingCliError` around most command handlers.
- `src/sing_cli/cli.py` owns timestamp parsing/formatting for list output, which may be a reusable formatting concern rather than command flow.
- Command functions mix command orchestration, state mutation, and user output; small extraction may improve readability without changing behavior.
- Existing service/profile/state modules are already small and should not be broadly rewritten without a concrete payoff.
- The user selected full project code review scope across `src/sing_cli/`.

## Requirements

- Preserve all existing command names, outputs, errors, state format, and tests unless a specific behavior change is explicitly approved.
- Review `src/sing_cli/cli.py`, `src/sing_cli/state.py`, `src/sing_cli/service.py`, and `src/sing_cli/profile.py`.
- Prefer focused readability improvements over broad architecture churn.
- Keep exception scopes narrow and preserve error context.
- Keep public command entry points obvious.
- Add or update tests only when behavior is intentionally touched or a refactor needs regression coverage.

## Acceptance Criteria

- [x] The chosen optimization scope is explicit.
- [x] Code changes are localized and improve readability or change isolation.
- [x] Ruff, ty, and pytest pass.
- [x] No backward-incompatible CLI behavior changes are introduced.

## Out of Scope

- Performance tuning without measured bottlenecks.
- Replacing Typer or changing the command set.
- Adding new features.
- Large rewrites across all modules.

## Technical Notes

- Relevant specs: `.trellis/spec/backend/index.md`, `.trellis/spec/backend/quality-guidelines.md`, `.trellis/spec/backend/error-handling.md`, `.trellis/spec/backend/logging-guidelines.md`.
- Likely files: `src/sing_cli/cli.py`, possibly small helpers in `src/sing_cli/state.py` or a narrow new module if justified.
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"id": "optimize-python-code",
"name": "optimize-python-code",
"title": "Optimize Python code",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "pixelcola",
"assignee": "pixelcola",
"createdAt": "2026-05-13",
"completedAt": "2026-05-13",
"branch": "refactor/optimize-python-code",
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
5 changes: 3 additions & 2 deletions .trellis/workspace/pixelcola/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

<!-- @@@auto:current-status -->
- **Active File**: `journal-1.md`
- **Total Sessions**: 8
- **Total Sessions**: 9
- **Last Active**: 2026-05-13
<!-- @@@/auto:current-status -->

Expand All @@ -19,7 +19,7 @@
<!-- @@@auto:active-documents -->
| File | Lines | Status |
|------|-------|--------|
| `journal-1.md` | ~271 | Active |
| `journal-1.md` | ~304 | Active |
<!-- @@@/auto:active-documents -->

---
Expand All @@ -29,6 +29,7 @@
<!-- @@@auto:session-history -->
| # | Date | Title | Commits | Branch |
|---|------|-------|---------|--------|
| 9 | 2026-05-13 | Optimize Python code | `8c0ae39` | `refactor/optimize-python-code` |
| 8 | 2026-05-13 | Use local timezone in sing list | `c6a44bf` | `fix/list-local-timezone` |
| 7 | 2026-05-12 | Expand test coverage | `764205a` | `test/improve-coverage` |
| 6 | 2026-05-12 | Update README installation instructions | `49680f6` | `docs/readme-install-uv-tool` |
Expand Down
33 changes: 33 additions & 0 deletions .trellis/workspace/pixelcola/journal-1.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,3 +269,36 @@ Changed sing list to render stored UTC profile update timestamps in the computer
### Next Steps

- None - task complete


## Session 9: Optimize Python code

**Date**: 2026-05-13
**Task**: Optimize Python code
**Branch**: `refactor/optimize-python-code`

### Summary

Refactored CLI command error handling through a shared SingCliError reporting boundary, split profile download into fetch/validate/write steps, documented the CLI boundary convention, verified pytest, Ruff, and ty, committed the refactor, and opened PR #10.

### Main Changes

(Add details)

### Git Commits

| Hash | Message |
|------|---------|
| `8c0ae39` | (see git log) |

### Testing

- [OK] (Add test results)

### Status

[OK] **Completed**

### Next Steps

- None - task complete
151 changes: 73 additions & 78 deletions src/sing_cli/cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

from collections.abc import Callable
from datetime import datetime
from functools import wraps
from pathlib import Path

import typer
Expand Down Expand Up @@ -62,6 +64,17 @@ def fail(error: SingCliError) -> None:
raise typer.Exit(1)


def report_cli_errors[**P](command: Callable[P, None]) -> Callable[P, None]:
@wraps(command)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> None:
try:
command(*args, **kwargs)
except SingCliError as error:
fail(error)

return wrapper


def to_local_timezone(value: datetime) -> datetime:
return value.astimezone()

Expand All @@ -79,124 +92,106 @@ def format_local_updated_at(profile_name: str, updated_at: str) -> str:


@app.command()
@report_cli_errors
def install(bin: Path | None = typer.Option(None, "--bin", help="Path to sing-box.exe.")) -> None:
try:
resolved_bin = resolve_bin(bin)
install_service(resolved_bin)
state = load_cli_state()
state.bin = str(resolved_bin)
save_cli_state(state)
except SingCliError as error:
fail(error)
resolved_bin = resolve_bin(bin)
install_service(resolved_bin)
state = load_cli_state()
state.bin = str(resolved_bin)
save_cli_state(state)
typer.echo(f"Installed {resolved_bin}")


@app.command()
@report_cli_errors
def uninstall() -> None:
try:
uninstall_service()
except SingCliError as error:
fail(error)
uninstall_service()
typer.echo("Uninstalled sing-box service")


@app.command()
@report_cli_errors
def start(name: str) -> None:
try:
state = load_cli_state()
entry = require_profile(state, name)
bin_path = require_installed_bin(state)
if service_is_running():
raise SingCliError("sing-box service is already running. Use 'sing restart'.")
configure_service(bin_path, entry.path)
start_service()
state.active = name
save_cli_state(state)
except SingCliError as error:
fail(error)
state = load_cli_state()
entry = require_profile(state, name)
bin_path = require_installed_bin(state)
if service_is_running():
raise SingCliError("sing-box service is already running. Use 'sing restart'.")
configure_service(bin_path, entry.path)
start_service()
state.active = name
save_cli_state(state)
typer.echo(f"Started {name}")


@app.command()
@report_cli_errors
def stop() -> None:
try:
stop_service()
except SingCliError as error:
fail(error)
stop_service()
typer.echo("Stopped sing-box service")


@app.command()
@report_cli_errors
def restart() -> None:
try:
state = load_cli_state()
if state.active is None:
raise SingCliError("No active profile. Run 'sing start <name>' first.")
entry = require_profile(state, state.active)
bin_path = require_installed_bin(state)
stop_service()
configure_service(bin_path, entry.path)
start_service()
except SingCliError as error:
fail(error)
state = load_cli_state()
if state.active is None:
raise SingCliError("No active profile. Run 'sing start <name>' first.")
entry = require_profile(state, state.active)
bin_path = require_installed_bin(state)
stop_service()
configure_service(bin_path, entry.path)
start_service()
typer.echo(f"Restarted {state.active}")


@app.command()
@report_cli_errors
def add(name: str, url: str) -> None:
try:
validate_profile_name(name)
state = load_cli_state()
if name in state.profiles:
raise SingCliError(f"Profile already exists: {name}")
destination = profile_path(name)
download_profile(url, destination)
state.profiles[name] = ProfileEntry(url=url, path=str(destination), updated_at=now_utc())
save_cli_state(state)
except SingCliError as error:
fail(error)
validate_profile_name(name)
state = load_cli_state()
if name in state.profiles:
raise SingCliError(f"Profile already exists: {name}")
destination = profile_path(name)
download_profile(url, destination)
state.profiles[name] = ProfileEntry(url=url, path=str(destination), updated_at=now_utc())
save_cli_state(state)
typer.echo(f"Added {name}")


@app.command()
@report_cli_errors
def remove(name: str) -> None:
try:
state = load_cli_state()
entry = require_profile(state, name)
if state.active == name:
raise SingCliError(f"Cannot remove active profile: {name}")
Path(entry.path).unlink()
del state.profiles[name]
save_cli_state(state)
except SingCliError as error:
fail(error)
state = load_cli_state()
entry = require_profile(state, name)
if state.active == name:
raise SingCliError(f"Cannot remove active profile: {name}")
Path(entry.path).unlink()
del state.profiles[name]
save_cli_state(state)
typer.echo(f"Removed {name}")


@app.command()
@report_cli_errors
def update(name: str) -> None:
try:
state = load_cli_state()
entry = require_profile(state, name)
download_profile(entry.url, Path(entry.path))
state.profiles[name] = ProfileEntry(url=entry.url, path=entry.path, updated_at=now_utc())
save_cli_state(state)
except SingCliError as error:
fail(error)
state = load_cli_state()
entry = require_profile(state, name)
download_profile(entry.url, Path(entry.path))
state.profiles[name] = ProfileEntry(url=entry.url, path=entry.path, updated_at=now_utc())
save_cli_state(state)
typer.echo(f"Updated {name}")


@app.command(name="list")
@report_cli_errors
def list_profiles() -> None:
try:
state = load_cli_state()
state = load_cli_state()

if not state.profiles:
typer.echo("No profiles")
return
if not state.profiles:
typer.echo("No profiles")
return

for name, entry in sorted(state.profiles.items()):
marker = "*" if state.active == name else " "
typer.echo(f"{marker} {name}\t{entry.url}\t{format_local_updated_at(name, entry.updated_at)}")
except SingCliError as error:
fail(error)
for name, entry in sorted(state.profiles.items()):
marker = "*" if state.active == name else " "
typer.echo(f"{marker} {name}\t{entry.url}\t{format_local_updated_at(name, entry.updated_at)}")
Loading