Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf5e74d80a | |||
| 1c51139883 | |||
| 1632828372 | |||
| f7fc33fa4a | |||
| 5a0b93448c |
161
README.md
161
README.md
@@ -12,7 +12,7 @@ pip install jiangchang-platform-kit --index-url https://git.jc2009.com/api/packa
|
||||
|
||||
## 3. 包含的模块
|
||||
|
||||
- **jiangchang_skill_core**:权益 HTTP 客户端、`enforce_entitlement`、统一文件日志、`JIANGCHANG_*` 数据根与技能根路径解析、共享媒体资源(`media_assets`)等,供 Skill CLI / 服务进程使用。
|
||||
- **jiangchang_skill_core**:权益 HTTP 客户端、`enforce_entitlement`、统一文件日志、`JIANGCHANG_*` 数据根与技能根路径解析、**Skill Activity Stream / Job 运行时**(`emit` / `finish` / Run Journal)、兄弟技能 CLI 桥(`sibling_bridge`)、共享媒体资源(`media_assets`)等,供 Skill CLI / 服务进程使用。
|
||||
- **jiangchang_desktop_sdk**:匠厂桌面应用自动化客户端(基于 CDP + Playwright 同步 API)。**本包不声明 Playwright 依赖**;请在运行环境中自行安装 Playwright,以便 `import playwright` 可用。
|
||||
|
||||
## 4. 快速开始
|
||||
@@ -56,7 +56,7 @@ finally:
|
||||
|
||||
### 桌面 E2E(`jiangchang_desktop_sdk.e2e_helpers`)
|
||||
|
||||
Skill 的桌面端到端测试应通过宿主 IPC 获取**真实**当前用户数据目录,不要在 pytest 进程里手工设置 `JIANGCHANG_USER_ID` / `CLAW_USER_ID` 来伪造用户(否则会回退到 `_anon`)。
|
||||
Skill 的桌面端到端测试应通过宿主 IPC 获取**真实**当前用户数据目录,不要在 pytest 进程里手工设置 `JIANGCHANG_USER_ID` 来伪造用户(否则会回退到 `_anon`)。
|
||||
|
||||
推荐 preflight 流程:
|
||||
|
||||
@@ -78,6 +78,163 @@ assert_skill_env_file(ctx, ["API_KEY"])
|
||||
send_prompt_via_composer(page, "第一行\n第二行")
|
||||
```
|
||||
|
||||
## Skill Activity Stream / Job 运行时
|
||||
|
||||
长任务(批量生成、RPA)需要把**逐步进度**推到匠厂宿主 UI,而不能依赖 Agent 轮询或 OpenClaw stdout patch。`jiangchang_skill_core.activity` 提供 **Skill Activity Stream (SAS) v1** 契约:
|
||||
|
||||
| API | 作用 |
|
||||
|-----|------|
|
||||
| `emit(text, ...)` | 写 Run Journal(`activity` / `progress` / `warn` / `debug`) |
|
||||
| `step(text)` | `emit(type='activity')` 别名 |
|
||||
| `finish(status=..., **fields)` | 写终态事件 + **唯一**向 stdout 输出单行 result JSON |
|
||||
| `rpa_step(name)` | 装饰器:闸门 + 进入/成功/失败自动 emit |
|
||||
| `checkpoint()` | consult control(不写步骤事件) |
|
||||
| `interruptible_sleep(sec)` | 可暂停/停止的 async sleep |
|
||||
| `JobStopped` | 宿主 stop 时抛出 |
|
||||
| `job_context(...)` | 可选包裹 main;异常时自动 `finish(failed)` |
|
||||
|
||||
### Run Journal 路径(宿主 tail 此文件)
|
||||
|
||||
```
|
||||
{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/{job_id}.jsonl
|
||||
```
|
||||
|
||||
- `job_id`:优先 `JIANGCHANG_JOB_ID`,否则进程内 `uuid4().hex`(首次 emit 时生成并缓存)
|
||||
- `JIANGCHANG_RUN_MODE`:`job` | `cli`(默认 `cli`),首行 meta 事件记录
|
||||
- NDJSON,UTF-8,append-only,每行 flush
|
||||
- `debug` 类型仅当 `JIANGCHANG_ACTIVITY_DEBUG=1` 时写入
|
||||
|
||||
### 与 Agent / OpenClaw 的分工
|
||||
|
||||
- **`emit()` / journal**:永不 print 到 stdout,避免 bash tool 结果淹没 Agent context
|
||||
- **`finish()`**:唯一允许向 stdout 写业务结果的 API;单行 compact JSON,`status` + 摘要字段,默认 ≤4096 字符
|
||||
- 技能里手写 `print("OK")`、pretty JSON 应逐步改为 `finish()`;RPA 步骤用 `emit("打开浏览器")` 或 `@rpa_step`
|
||||
|
||||
### GEO batch 示例
|
||||
|
||||
```python
|
||||
from jiangchang_skill_core.activity import emit, finish
|
||||
|
||||
for i, item in enumerate(items, 1):
|
||||
emit(f"生成 {item['title']}", type="progress", current=i, total=len(items), skill="geo-batch")
|
||||
# ... business logic ...
|
||||
|
||||
finish(status="success", skill="geo-batch", generated=len(items))
|
||||
```
|
||||
|
||||
### RPA 步骤示例
|
||||
|
||||
```python
|
||||
from jiangchang_skill_core.activity import emit, rpa_step
|
||||
|
||||
@rpa_step("打开浏览器")
|
||||
def open_browser(page):
|
||||
page.goto("https://example.com")
|
||||
|
||||
emit("手动步骤说明", skill="publish-toutiao", stage="rpa")
|
||||
```
|
||||
|
||||
`RpaVideoSession.add_step()` 在录屏字幕之外默认同步 `emit(..., stage="rpa.video")`;可通过 `emit_activity=False` 关闭。
|
||||
|
||||
## Skill Runtime Control Protocol (SRCP) v1 — 1.2.0+
|
||||
|
||||
宿主通过 **control 文件**向技能进程下发暂停 / 继续 / 停止;platform-kit 在**步骤闸门**自动读取,技能作者无需手写控制逻辑。
|
||||
|
||||
### control.json(宿主写、kit 读)
|
||||
|
||||
```
|
||||
{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/{job_id}.control.json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"command": "none",
|
||||
"reason": "user",
|
||||
"ts": 1730000000000,
|
||||
"nonce": 1
|
||||
}
|
||||
```
|
||||
|
||||
| command | 行为 |
|
||||
|---------|------|
|
||||
| `none` | 正常运行 |
|
||||
| `pause` | 下一步闸门进入暂停(阻塞直到 resume 或 stop) |
|
||||
| `resume` | 从暂停恢复 |
|
||||
| `stop` | 协作式停止,抛出 `JobStopped` |
|
||||
|
||||
宿主更新命令时应递增 `nonce`(kit 每次闸门重新读文件,不依赖缓存)。
|
||||
|
||||
### 步骤闸门(自动挂载)
|
||||
|
||||
| 调用点 | 闸门行为 |
|
||||
|--------|----------|
|
||||
| `emit()` / `step()` | 进入步骤前 gate + 写 Journal |
|
||||
| `@rpa_step` | 进入步骤前 gate,再写 ▶ / ✓ |
|
||||
| `RpaVideoSession.add_step()` | 经 `emit()` 自动 gate |
|
||||
| `interruptible_sleep()` | 每 200ms 切片 `checkpoint()`(可暂停/停止) |
|
||||
|
||||
暂停在**当前步骤边界**生效(与影刀「当前指令执行完后暂停」一致),不在 DOM 原子操作中途冻结。
|
||||
|
||||
### lifecycle Journal 事件
|
||||
|
||||
闸门写入 `type: lifecycle` 事件,供宿主任务中心显示运行 / 暂停 / 继续状态:
|
||||
|
||||
```json
|
||||
{"type":"lifecycle","status":"paused","text":"已暂停,等待继续","step_index":3,"step_name":"打开页面",...}
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
| API | 作用 |
|
||||
|-----|------|
|
||||
| `checkpoint()` | 仅 consult control(长等待循环内使用) |
|
||||
| `interruptible_sleep(sec)` | async 可中断 sleep |
|
||||
| `interruptible_sleep_sync(sec)` | sync 可中断 sleep |
|
||||
| `JobStopped` | 宿主 stop 时抛出;`job_context` 自动 `finish(..., error_code="JOB_STOPPED")` |
|
||||
| `get_control_path()` | 当前 job 的 control 文件路径(诊断) |
|
||||
|
||||
环境变量:
|
||||
|
||||
- `JIANGCHANG_CONTROL_POLL_SEC` — 暂停轮询间隔(默认 `0.2`)
|
||||
- `JIANGCHANG_CONTROL_DISABLED=1` — 禁用闸门(仅测试)
|
||||
|
||||
### RPA 示例(拆步 + 可中断等待)
|
||||
|
||||
```python
|
||||
from jiangchang_skill_core.activity import (
|
||||
emit,
|
||||
finish,
|
||||
job_context,
|
||||
interruptible_sleep,
|
||||
rpa_step,
|
||||
)
|
||||
|
||||
def cmd_run(...):
|
||||
with job_context(skill="demo-skill"):
|
||||
emit("开始任务", skill="demo-skill", stage="run")
|
||||
|
||||
@rpa_step("打开后台")
|
||||
async def open_admin(page):
|
||||
await page.goto("https://example.com")
|
||||
await interruptible_sleep(1.5)
|
||||
|
||||
await open_admin(page)
|
||||
finish(status="success", message="完成", skill="demo-skill")
|
||||
```
|
||||
|
||||
技能作者规范以 **skill-template** 为准;本 README 为实现细节参考。
|
||||
|
||||
### 兄弟技能 CLI 桥
|
||||
|
||||
```python
|
||||
from jiangchang_skill_core import call_sibling_json, get_sibling_main_path
|
||||
|
||||
accounts = call_sibling_json("account-manager", ["list", "all"])
|
||||
```
|
||||
|
||||
同步 `subprocess.run`,`capture_output=True`,自动带上 `subprocess_env_with_trace()`。
|
||||
|
||||
## 共享媒体资源
|
||||
|
||||
`jiangchang_skill_core.media_assets` 提供共享媒体资源解析能力。默认会从:
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jiangchang-platform-kit"
|
||||
version = "1.0.14"
|
||||
version = "1.2.0"
|
||||
description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
from .activity import (
|
||||
JobStopped,
|
||||
checkpoint,
|
||||
emit,
|
||||
finish,
|
||||
get_control_path,
|
||||
get_job_id,
|
||||
get_run_mode,
|
||||
interruptible_sleep,
|
||||
interruptible_sleep_sync,
|
||||
job_context,
|
||||
rpa_step,
|
||||
step,
|
||||
)
|
||||
from .sibling_bridge import (
|
||||
call_sibling_json,
|
||||
call_sibling_json_array,
|
||||
get_sibling_main_path,
|
||||
)
|
||||
from .client import EntitlementClient
|
||||
from .config import ensure_env_file, get, get_bool, get_float, get_int
|
||||
from .errors import EntitlementDeniedError, EntitlementError, EntitlementServiceError
|
||||
@@ -5,8 +24,10 @@ from .guard import enforce_entitlement
|
||||
from .models import EntitlementResult
|
||||
from .runtime_env import (
|
||||
apply_cli_local_defaults,
|
||||
ensure_runs_dir,
|
||||
find_chrome_executable,
|
||||
get_data_root,
|
||||
get_runs_dir,
|
||||
get_sibling_skills_root,
|
||||
get_skills_root,
|
||||
get_user_id,
|
||||
@@ -52,6 +73,23 @@ except ImportError:
|
||||
rpa = None # type: ignore[assignment,misc]
|
||||
|
||||
__all__ = [
|
||||
"JobStopped",
|
||||
"call_sibling_json",
|
||||
"call_sibling_json_array",
|
||||
"checkpoint",
|
||||
"emit",
|
||||
"finish",
|
||||
"get_control_path",
|
||||
"get_job_id",
|
||||
"get_run_mode",
|
||||
"get_runs_dir",
|
||||
"ensure_runs_dir",
|
||||
"interruptible_sleep",
|
||||
"interruptible_sleep_sync",
|
||||
"job_context",
|
||||
"rpa_step",
|
||||
"step",
|
||||
"get_sibling_main_path",
|
||||
"EntitlementClient",
|
||||
"EntitlementDeniedError",
|
||||
"EntitlementError",
|
||||
@@ -88,6 +126,7 @@ __all__ = [
|
||||
"get_data_root",
|
||||
"get_float",
|
||||
"get_int",
|
||||
"get_runs_dir",
|
||||
"get_sibling_skills_root",
|
||||
"get_skills_root",
|
||||
"get_skill_log_file_path",
|
||||
|
||||
546
src/jiangchang_skill_core/activity.py
Normal file
546
src/jiangchang_skill_core/activity.py
Normal file
@@ -0,0 +1,546 @@
|
||||
"""
|
||||
Skill Activity Stream (SAS) v1 + Skill Runtime Control Protocol (SRCP) v1.
|
||||
|
||||
emit() / journal events never touch stdout (Agent/bash tool result stays clean).
|
||||
finish() is the only API that writes a single-line result JSON to stdout.
|
||||
|
||||
SRCP: host writes {job_id}.control.json; kit consults at step gates (emit,
|
||||
rpa_step, interruptible_sleep) for pause / resume / stop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Callable, Iterator, Optional, TypeVar
|
||||
|
||||
from .runtime_env import ensure_runs_dir, get_runs_dir
|
||||
|
||||
_SCHEMA_VERSION = 1
|
||||
_CONTROL_SCHEMA_VERSION = 1
|
||||
_VALID_EMIT_TYPES = frozenset({"activity", "progress", "warn", "debug"})
|
||||
_VALID_CONTROL_COMMANDS = frozenset({"none", "pause", "resume", "stop"})
|
||||
_RESULT_MAX_CHARS = 4096
|
||||
_DEFAULT_CONTROL_POLL_SEC = 0.2
|
||||
|
||||
_cached_job_id: Optional[str] = None
|
||||
_journal_path: Optional[str] = None
|
||||
_meta_written: bool = False
|
||||
_journal_file: Optional[Any] = None
|
||||
_step_index: int = 0
|
||||
_current_step_name: Optional[str] = None
|
||||
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
|
||||
class JobStopped(Exception):
|
||||
"""Raised when the host requests stop via control.json."""
|
||||
|
||||
|
||||
def _activity_debug_enabled() -> bool:
|
||||
v = (os.getenv("JIANGCHANG_ACTIVITY_DEBUG") or "").strip().lower()
|
||||
return v in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _control_disabled() -> bool:
|
||||
v = (os.getenv("JIANGCHANG_CONTROL_DISABLED") or "").strip().lower()
|
||||
return v in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _control_poll_sec() -> float:
|
||||
raw = (os.getenv("JIANGCHANG_CONTROL_POLL_SEC") or "").strip()
|
||||
if not raw:
|
||||
return _DEFAULT_CONTROL_POLL_SEC
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
return _DEFAULT_CONTROL_POLL_SEC
|
||||
return max(0.05, value)
|
||||
|
||||
|
||||
def get_run_mode() -> str:
|
||||
mode = (os.getenv("JIANGCHANG_RUN_MODE") or "").strip().lower()
|
||||
if mode in ("job", "cli"):
|
||||
return mode
|
||||
return "cli"
|
||||
|
||||
|
||||
def get_job_id() -> str:
|
||||
global _cached_job_id
|
||||
env_job = (os.getenv("JIANGCHANG_JOB_ID") or "").strip()
|
||||
if env_job:
|
||||
return env_job
|
||||
if _cached_job_id is None:
|
||||
_cached_job_id = uuid.uuid4().hex
|
||||
return _cached_job_id
|
||||
|
||||
|
||||
def get_control_path() -> str:
|
||||
"""Path to the host-owned control file for the current job."""
|
||||
ensure_runs_dir()
|
||||
return os.path.join(get_runs_dir(), f"{get_job_id()}.control.json")
|
||||
|
||||
|
||||
def _get_journal_path() -> str:
|
||||
global _journal_path
|
||||
if _journal_path is None:
|
||||
ensure_runs_dir()
|
||||
_journal_path = os.path.join(get_runs_dir(), f"{get_job_id()}.jsonl")
|
||||
return _journal_path
|
||||
|
||||
|
||||
def _open_journal() -> Any:
|
||||
global _journal_file
|
||||
if _journal_file is None:
|
||||
path = _get_journal_path()
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
_journal_file = open(path, "a", encoding="utf-8")
|
||||
return _journal_file
|
||||
|
||||
|
||||
def _write_journal_line(event: dict[str, Any]) -> None:
|
||||
global _meta_written
|
||||
f = _open_journal()
|
||||
if not _meta_written:
|
||||
meta = {
|
||||
"type": "meta",
|
||||
"schema_version": _SCHEMA_VERSION,
|
||||
"job_id": get_job_id(),
|
||||
"run_mode": get_run_mode(),
|
||||
"ts": _now_ms(),
|
||||
}
|
||||
f.write(json.dumps(meta, ensure_ascii=False, separators=(",", ":")) + "\n")
|
||||
f.flush()
|
||||
_meta_written = True
|
||||
f.write(json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n")
|
||||
f.flush()
|
||||
|
||||
|
||||
def _now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def _read_control_payload() -> dict[str, Any] | None:
|
||||
path = get_control_path()
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def _read_control_command() -> str:
|
||||
payload = _read_control_payload()
|
||||
if not payload:
|
||||
return "none"
|
||||
command = payload.get("command")
|
||||
if not isinstance(command, str):
|
||||
return "none"
|
||||
normalized = command.strip().lower()
|
||||
if normalized not in _VALID_CONTROL_COMMANDS:
|
||||
return "none"
|
||||
return normalized
|
||||
|
||||
|
||||
def _write_lifecycle_event(
|
||||
status: str,
|
||||
text: str,
|
||||
*,
|
||||
step_name: str | None = None,
|
||||
skill: str | None = None,
|
||||
) -> None:
|
||||
event: dict[str, Any] = {
|
||||
"type": "lifecycle",
|
||||
"status": status,
|
||||
"text": text,
|
||||
"schema_version": _SCHEMA_VERSION,
|
||||
"control_schema_version": _CONTROL_SCHEMA_VERSION,
|
||||
"ts": _now_ms(),
|
||||
"step_index": _step_index,
|
||||
}
|
||||
name = step_name or _current_step_name
|
||||
if name:
|
||||
event["step_name"] = name
|
||||
if skill is not None:
|
||||
event["skill"] = skill
|
||||
_write_journal_line(event)
|
||||
|
||||
|
||||
def _wait_while_paused(*, step_name: str | None = None) -> None:
|
||||
poll = _control_poll_sec()
|
||||
while True:
|
||||
command = _read_control_command()
|
||||
if command == "stop":
|
||||
_write_lifecycle_event("stopping", "任务已停止", step_name=step_name)
|
||||
raise JobStopped("Job stopped by host.")
|
||||
if command in ("none", "resume"):
|
||||
_write_lifecycle_event("resumed", "继续执行", step_name=step_name)
|
||||
return
|
||||
time.sleep(poll)
|
||||
|
||||
|
||||
def _apply_control(*, advance_step: bool, step_name: str | None = None) -> None:
|
||||
if _control_disabled():
|
||||
return
|
||||
|
||||
global _step_index, _current_step_name
|
||||
if advance_step:
|
||||
_step_index += 1
|
||||
if step_name:
|
||||
_current_step_name = step_name
|
||||
|
||||
command = _read_control_command()
|
||||
if command == "stop":
|
||||
_write_lifecycle_event("stopping", "任务已停止", step_name=step_name)
|
||||
raise JobStopped("Job stopped by host.")
|
||||
if command == "pause":
|
||||
_write_lifecycle_event("paused", "已暂停,等待继续", step_name=step_name)
|
||||
_wait_while_paused(step_name=step_name)
|
||||
|
||||
|
||||
def checkpoint() -> None:
|
||||
"""
|
||||
Consult host control without advancing the step index.
|
||||
|
||||
Use inside long waits (interruptible_sleep) between step boundaries.
|
||||
"""
|
||||
_apply_control(advance_step=False)
|
||||
|
||||
|
||||
def _step_gate(step_name: str | None) -> None:
|
||||
"""Step boundary: advance index, then pause/stop gate."""
|
||||
_apply_control(advance_step=True, step_name=step_name)
|
||||
|
||||
|
||||
def _build_event(
|
||||
type_: str,
|
||||
text: str,
|
||||
*,
|
||||
skill: str | None = None,
|
||||
stage: str | None = None,
|
||||
current: int | None = None,
|
||||
total: int | None = None,
|
||||
item_id: str | None = None,
|
||||
ts: int | None = None,
|
||||
**extra: Any,
|
||||
) -> dict[str, Any]:
|
||||
cleaned = (text or "").strip()
|
||||
if not cleaned:
|
||||
raise ValueError("text must be non-empty")
|
||||
event: dict[str, Any] = {
|
||||
"type": type_,
|
||||
"text": cleaned,
|
||||
"schema_version": _SCHEMA_VERSION,
|
||||
"ts": ts if ts is not None else _now_ms(),
|
||||
}
|
||||
if skill is not None:
|
||||
event["skill"] = skill
|
||||
if stage is not None:
|
||||
event["stage"] = stage
|
||||
if current is not None:
|
||||
event["current"] = current
|
||||
if total is not None:
|
||||
event["total"] = total
|
||||
if item_id is not None:
|
||||
event["item_id"] = item_id
|
||||
for key, value in extra.items():
|
||||
if key not in event:
|
||||
event[key] = value
|
||||
return event
|
||||
|
||||
|
||||
def _emit_core(
|
||||
text: str,
|
||||
*,
|
||||
type: str = "activity",
|
||||
skill: str | None = None,
|
||||
stage: str | None = None,
|
||||
current: int | None = None,
|
||||
total: int | None = None,
|
||||
item_id: str | None = None,
|
||||
**extra: Any,
|
||||
) -> None:
|
||||
if type == "debug" and not _activity_debug_enabled():
|
||||
return
|
||||
if type not in _VALID_EMIT_TYPES:
|
||||
raise ValueError(f"invalid emit type: {type!r}")
|
||||
event = _build_event(
|
||||
type,
|
||||
text,
|
||||
skill=skill,
|
||||
stage=stage,
|
||||
current=current,
|
||||
total=total,
|
||||
item_id=item_id,
|
||||
**extra,
|
||||
)
|
||||
_write_journal_line(event)
|
||||
|
||||
|
||||
def emit(
|
||||
text: str,
|
||||
*,
|
||||
type: str = "activity",
|
||||
skill: str | None = None,
|
||||
stage: str | None = None,
|
||||
current: int | None = None,
|
||||
total: int | None = None,
|
||||
item_id: str | None = None,
|
||||
**extra: Any,
|
||||
) -> None:
|
||||
"""Append one SAS event to the Run Journal. Never writes to stdout."""
|
||||
gate_name = stage or text
|
||||
_step_gate(gate_name)
|
||||
_emit_core(
|
||||
text,
|
||||
type=type,
|
||||
skill=skill,
|
||||
stage=stage,
|
||||
current=current,
|
||||
total=total,
|
||||
item_id=item_id,
|
||||
**extra,
|
||||
)
|
||||
|
||||
|
||||
def step(text: str, **kwargs: Any) -> None:
|
||||
"""Shorthand for emit(type='activity')."""
|
||||
emit(text, type="activity", **kwargs)
|
||||
|
||||
|
||||
async def interruptible_sleep(seconds: float, *, chunk: float | None = None) -> None:
|
||||
"""asyncio.sleep in slices; each slice consults host control (pause/stop)."""
|
||||
if seconds <= 0:
|
||||
return
|
||||
poll = chunk if chunk is not None else _control_poll_sec()
|
||||
poll = max(0.05, poll)
|
||||
deadline = time.monotonic() + seconds
|
||||
while True:
|
||||
checkpoint()
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return
|
||||
await asyncio.sleep(min(poll, remaining))
|
||||
|
||||
|
||||
def interruptible_sleep_sync(seconds: float, *, chunk: float | None = None) -> None:
|
||||
"""time.sleep in slices; each slice consults host control (pause/stop)."""
|
||||
if seconds <= 0:
|
||||
return
|
||||
poll = chunk if chunk is not None else _control_poll_sec()
|
||||
poll = max(0.05, poll)
|
||||
deadline = time.monotonic() + seconds
|
||||
while True:
|
||||
checkpoint()
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return
|
||||
time.sleep(min(poll, remaining))
|
||||
|
||||
|
||||
def _serialize_result(result: dict[str, Any]) -> str:
|
||||
line = json.dumps(result, ensure_ascii=False, separators=(",", ":"))
|
||||
if len(line) <= _RESULT_MAX_CHARS:
|
||||
return line
|
||||
|
||||
core_keys = ("type", "schema_version", "status", "skill", "message")
|
||||
truncated: dict[str, Any] = {
|
||||
k: result[k] for k in core_keys if k in result
|
||||
}
|
||||
truncated["truncated"] = True
|
||||
|
||||
for key, value in result.items():
|
||||
if key in truncated or key == "truncated":
|
||||
continue
|
||||
candidate = {**truncated, key: value}
|
||||
candidate_line = json.dumps(candidate, ensure_ascii=False, separators=(",", ":"))
|
||||
if len(candidate_line) <= _RESULT_MAX_CHARS:
|
||||
truncated[key] = value
|
||||
|
||||
line = json.dumps(truncated, ensure_ascii=False, separators=(",", ":"))
|
||||
if len(line) > _RESULT_MAX_CHARS:
|
||||
truncated.pop("message", None)
|
||||
line = json.dumps(truncated, ensure_ascii=False, separators=(",", ":"))
|
||||
if len(line) > _RESULT_MAX_CHARS:
|
||||
return line[:_RESULT_MAX_CHARS]
|
||||
return line
|
||||
|
||||
|
||||
def finish(
|
||||
*,
|
||||
status: str = "success",
|
||||
message: str | None = None,
|
||||
skill: str | None = None,
|
||||
**fields: Any,
|
||||
) -> None:
|
||||
"""
|
||||
1) Write terminal done|error event to Run Journal.
|
||||
2) Print a single compact result JSON line to stdout (Agent/bash path).
|
||||
"""
|
||||
if status not in ("success", "partial", "failed"):
|
||||
raise ValueError(f"invalid finish status: {status!r}")
|
||||
|
||||
journal_type = "error" if status == "failed" else "done"
|
||||
terminal_text = message or ("任务失败" if status == "failed" else "任务完成")
|
||||
|
||||
journal_event = _build_event(
|
||||
journal_type,
|
||||
terminal_text,
|
||||
skill=skill,
|
||||
status=status,
|
||||
**fields,
|
||||
)
|
||||
if message is not None:
|
||||
journal_event["message"] = message
|
||||
_write_journal_line(journal_event)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"type": "result",
|
||||
"schema_version": _SCHEMA_VERSION,
|
||||
"status": status,
|
||||
}
|
||||
if skill is not None:
|
||||
result["skill"] = skill
|
||||
if message is not None:
|
||||
result["message"] = message
|
||||
result.update(fields)
|
||||
|
||||
line = _serialize_result(result)
|
||||
sys.stdout.write(line + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def job_context(
|
||||
*,
|
||||
job_id: str | None = None,
|
||||
skill: str | None = None,
|
||||
run_mode: str | None = None,
|
||||
) -> Iterator[None]:
|
||||
"""Optional wrapper: sets job context; auto finish(failed) on unhandled exception."""
|
||||
global _cached_job_id, _journal_path, _meta_written, _journal_file
|
||||
|
||||
saved_job_id = _cached_job_id
|
||||
saved_journal_path = _journal_path
|
||||
saved_meta = _meta_written
|
||||
saved_file = _journal_file
|
||||
saved_env_job = os.environ.get("JIANGCHANG_JOB_ID")
|
||||
saved_env_mode = os.environ.get("JIANGCHANG_RUN_MODE")
|
||||
|
||||
if job_id:
|
||||
os.environ["JIANGCHANG_JOB_ID"] = job_id
|
||||
_cached_job_id = None
|
||||
_journal_path = None
|
||||
_meta_written = False
|
||||
if _journal_file is not None:
|
||||
try:
|
||||
_journal_file.close()
|
||||
except Exception:
|
||||
pass
|
||||
_journal_file = None
|
||||
if run_mode:
|
||||
os.environ["JIANGCHANG_RUN_MODE"] = run_mode
|
||||
|
||||
try:
|
||||
yield
|
||||
except JobStopped as exc:
|
||||
finish(
|
||||
status="failed",
|
||||
message=str(exc) or "用户停止",
|
||||
skill=skill,
|
||||
error_code="JOB_STOPPED",
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
finish(status="failed", message=str(exc), skill=skill)
|
||||
raise
|
||||
finally:
|
||||
_cached_job_id = saved_job_id
|
||||
_journal_path = saved_journal_path
|
||||
_meta_written = saved_meta
|
||||
if _journal_file is not None and _journal_file is not saved_file:
|
||||
try:
|
||||
_journal_file.close()
|
||||
except Exception:
|
||||
pass
|
||||
_journal_file = saved_file
|
||||
if saved_env_job is None:
|
||||
os.environ.pop("JIANGCHANG_JOB_ID", None)
|
||||
else:
|
||||
os.environ["JIANGCHANG_JOB_ID"] = saved_env_job
|
||||
if saved_env_mode is None:
|
||||
os.environ.pop("JIANGCHANG_RUN_MODE", None)
|
||||
else:
|
||||
os.environ["JIANGCHANG_RUN_MODE"] = saved_env_mode
|
||||
|
||||
|
||||
def rpa_step(name: str | None = None) -> Callable[[F], F]:
|
||||
"""Decorator for sync/async RPA steps: gate, emit enter/success/warn on failure."""
|
||||
|
||||
def decorator(fn: F) -> F:
|
||||
step_name = name or fn.__name__
|
||||
|
||||
if inspect.iscoroutinefunction(fn):
|
||||
|
||||
@functools.wraps(fn)
|
||||
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
_step_gate(step_name)
|
||||
_emit_core(f"▶ {step_name}")
|
||||
try:
|
||||
result = await fn(*args, **kwargs)
|
||||
_emit_core(f"✓ {step_name}")
|
||||
return result
|
||||
except JobStopped:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_emit_core(f"✗ {step_name}: {exc}", type="warn")
|
||||
raise
|
||||
|
||||
return async_wrapper # type: ignore[return-value]
|
||||
|
||||
@functools.wraps(fn)
|
||||
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
_step_gate(step_name)
|
||||
_emit_core(f"▶ {step_name}")
|
||||
try:
|
||||
result = fn(*args, **kwargs)
|
||||
_emit_core(f"✓ {step_name}")
|
||||
return result
|
||||
except JobStopped:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_emit_core(f"✗ {step_name}: {exc}", type="warn")
|
||||
raise
|
||||
|
||||
return sync_wrapper # type: ignore[return-value]
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def _reset_for_tests() -> None:
|
||||
"""Clear module-level journal and control state (tests only)."""
|
||||
global _cached_job_id, _journal_path, _meta_written, _journal_file
|
||||
global _step_index, _current_step_name
|
||||
if _journal_file is not None:
|
||||
try:
|
||||
_journal_file.close()
|
||||
except Exception:
|
||||
pass
|
||||
_cached_job_id = None
|
||||
_journal_path = None
|
||||
_meta_written = False
|
||||
_journal_file = None
|
||||
_step_index = 0
|
||||
_current_step_name = None
|
||||
@@ -743,7 +743,7 @@ def probe_background_music(
|
||||
if not music_dir.is_dir():
|
||||
music_dir = Path(root) if Path(root).is_dir() else None
|
||||
else:
|
||||
data_root = (env_map.get("JIANGCHANG_DATA_ROOT") or env_map.get("CLAW_DATA_ROOT") or "").strip()
|
||||
data_root = (env_map.get("JIANGCHANG_DATA_ROOT") or "").strip()
|
||||
assets_root = resolve_media_assets_root(data_root or None, env_map)
|
||||
status = _inspect_media_assets(assets_root, source="local")
|
||||
music_dir = status.music_dir
|
||||
|
||||
@@ -5,10 +5,11 @@ from __future__ import annotations
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from .. import config
|
||||
|
||||
|
||||
def _artifacts_enabled() -> bool:
|
||||
v = (os.environ.get("OPENCLAW_ARTIFACTS_ON_FAILURE") or "1").strip().lower()
|
||||
return v not in ("0", "false", "no", "off")
|
||||
return config.get_bool("OPENCLAW_ARTIFACTS_ON_FAILURE", default=True)
|
||||
|
||||
|
||||
def artifact_path(data_dir: str, batch_id: str, tag: str, ext: str = "png") -> str:
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .. import config
|
||||
from ..runtime_env import find_chrome_executable
|
||||
from .stealth import (
|
||||
STEALTH_INIT_SCRIPT,
|
||||
@@ -31,8 +31,7 @@ async def launch_persistent_browser(
|
||||
录屏由 RpaVideoSession(ffmpeg)负责,本函数不向 Playwright 传递任何录屏参数。
|
||||
"""
|
||||
if headless is None:
|
||||
v = (os.environ.get("OPENCLAW_BROWSER_HEADLESS") or "0").strip().lower()
|
||||
headless = v in ("1", "true", "yes", "on")
|
||||
headless = config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False)
|
||||
|
||||
chrome = executable_path or find_chrome_executable()
|
||||
if not chrome:
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from .. import config
|
||||
|
||||
STEALTH_INIT_SCRIPT = """
|
||||
(() => {
|
||||
@@ -48,8 +48,7 @@ STEALTH_INIT_SCRIPT = """
|
||||
|
||||
|
||||
def stealth_enabled() -> bool:
|
||||
v = (os.environ.get("OPENCLAW_PLAYWRIGHT_STEALTH") or "1").strip().lower()
|
||||
return v not in ("0", "false", "no", "off")
|
||||
return config.get_bool("OPENCLAW_PLAYWRIGHT_STEALTH", default=True)
|
||||
|
||||
|
||||
def persistent_context_launch_parts(
|
||||
|
||||
@@ -568,12 +568,14 @@ class RpaVideoSession:
|
||||
batch_id: str,
|
||||
title: str = "",
|
||||
closing_title: str = "",
|
||||
emit_activity: bool = True,
|
||||
) -> None:
|
||||
self.skill_slug = skill_slug
|
||||
self.skill_data_dir = os.path.abspath(skill_data_dir)
|
||||
self.batch_id = batch_id
|
||||
self.title = title
|
||||
self.closing_title = closing_title
|
||||
self.emit_activity = emit_activity
|
||||
self.enabled = _record_video_enabled()
|
||||
self.warnings: List[str] = []
|
||||
self.output_video_path: Optional[str] = None
|
||||
@@ -631,13 +633,25 @@ class RpaVideoSession:
|
||||
self.add_step(text, duration=duration)
|
||||
|
||||
def add_step(self, text: str, *, duration: float = 4.0) -> None:
|
||||
"""记录一步骤字幕(相对录屏开始时间)。"""
|
||||
"""记录一步骤字幕(相对录屏开始时间);可选同步写入 Activity Stream。"""
|
||||
cleaned = text.strip()
|
||||
if self.emit_activity and cleaned:
|
||||
try:
|
||||
from .. import activity
|
||||
|
||||
activity.emit(
|
||||
cleaned,
|
||||
skill=self.skill_slug,
|
||||
stage="rpa.video",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if not self.enabled:
|
||||
return
|
||||
if self._t0 is None:
|
||||
self._t0 = time.monotonic()
|
||||
elapsed = time.monotonic() - self._t0
|
||||
self._steps.append(_StepEntry(start=elapsed, text=text.strip(), duration=duration))
|
||||
self._steps.append(_StepEntry(start=elapsed, text=cleaned, duration=duration))
|
||||
|
||||
def _close_ffmpeg_record_log(self) -> None:
|
||||
fh = self._ffmpeg_record_log_file
|
||||
|
||||
@@ -11,6 +11,7 @@ from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
import jiangchang_skill_core
|
||||
from . import config
|
||||
from .media_assets import probe_background_music, probe_media_assets, resolve_media_assets_root
|
||||
from .runtime_env import platform_default_data_root
|
||||
|
||||
@@ -36,7 +37,6 @@ class RuntimeDiagnostics:
|
||||
platform_kit_version_ok: bool | None
|
||||
jiangchang_skill_core_file: str | None
|
||||
|
||||
claw_data_root: str | None
|
||||
jiangchang_data_root: str | None
|
||||
resolved_data_root: str | None
|
||||
|
||||
@@ -65,7 +65,7 @@ def _env_dict(env: Mapping[str, str] | None) -> dict[str, str]:
|
||||
|
||||
|
||||
def _resolve_data_root(env: Mapping[str, str]) -> str:
|
||||
root = (env.get("CLAW_DATA_ROOT") or env.get("JIANGCHANG_DATA_ROOT") or "").strip()
|
||||
root = (env.get("JIANGCHANG_DATA_ROOT") or "").strip()
|
||||
if root:
|
||||
return root
|
||||
return platform_default_data_root()
|
||||
@@ -163,7 +163,6 @@ def collect_runtime_diagnostics(
|
||||
env_map = _env_dict(env)
|
||||
issues: list[RuntimeIssue] = []
|
||||
|
||||
claw_data_root = env_map.get("CLAW_DATA_ROOT")
|
||||
jiangchang_data_root = env_map.get("JIANGCHANG_DATA_ROOT")
|
||||
resolved_data_root = _resolve_data_root(env_map)
|
||||
|
||||
@@ -230,7 +229,10 @@ def collect_runtime_diagnostics(
|
||||
)
|
||||
|
||||
if record_video is None:
|
||||
record_video_enabled = _bool_from_env(env_map, "OPENCLAW_RECORD_VIDEO", False)
|
||||
if env is not None:
|
||||
record_video_enabled = _bool_from_env(env_map, "OPENCLAW_RECORD_VIDEO", False)
|
||||
else:
|
||||
record_video_enabled = config.get_bool("OPENCLAW_RECORD_VIDEO", default=False)
|
||||
else:
|
||||
record_video_enabled = record_video
|
||||
|
||||
@@ -263,7 +265,6 @@ def collect_runtime_diagnostics(
|
||||
platform_kit_min_version=platform_kit_min_version,
|
||||
platform_kit_version_ok=platform_ok,
|
||||
jiangchang_skill_core_file=core_file,
|
||||
claw_data_root=claw_data_root,
|
||||
jiangchang_data_root=jiangchang_data_root,
|
||||
resolved_data_root=resolved_data_root,
|
||||
media_assets_root=str(media_root_path),
|
||||
@@ -286,7 +287,6 @@ def format_runtime_health_lines(diagnostics: RuntimeDiagnostics) -> list[str]:
|
||||
f"platform_kit_min_version: {diagnostics.platform_kit_min_version or ''}",
|
||||
f"platform_kit_version_ok: {diagnostics.platform_kit_version_ok}",
|
||||
f"jiangchang_skill_core_file: {diagnostics.jiangchang_skill_core_file or ''}",
|
||||
f"CLAW_DATA_ROOT: {diagnostics.claw_data_root or ''}",
|
||||
f"JIANGCHANG_DATA_ROOT: {diagnostics.jiangchang_data_root or ''}",
|
||||
f"resolved_data_root: {diagnostics.resolved_data_root or ''}",
|
||||
f"media_assets_root: {diagnostics.media_assets_root or ''}",
|
||||
@@ -311,7 +311,6 @@ def runtime_diagnostics_dict(diagnostics: RuntimeDiagnostics) -> dict[str, Any]:
|
||||
"platform_kit_min_version": diagnostics.platform_kit_min_version,
|
||||
"platform_kit_version_ok": diagnostics.platform_kit_version_ok,
|
||||
"jiangchang_skill_core_file": diagnostics.jiangchang_skill_core_file,
|
||||
"CLAW_DATA_ROOT": diagnostics.claw_data_root,
|
||||
"JIANGCHANG_DATA_ROOT": diagnostics.jiangchang_data_root,
|
||||
"resolved_data_root": diagnostics.resolved_data_root,
|
||||
"media_assets_root": diagnostics.media_assets_root,
|
||||
|
||||
@@ -34,22 +34,28 @@ def platform_default_data_root() -> str:
|
||||
|
||||
|
||||
def get_data_root() -> str:
|
||||
env = (
|
||||
os.getenv("CLAW_DATA_ROOT")
|
||||
or os.getenv("JIANGCHANG_DATA_ROOT")
|
||||
or ""
|
||||
).strip()
|
||||
env = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip()
|
||||
if env:
|
||||
return env
|
||||
return platform_default_data_root()
|
||||
|
||||
|
||||
def get_user_id() -> str:
|
||||
return (
|
||||
os.getenv("CLAW_USER_ID")
|
||||
or os.getenv("JIANGCHANG_USER_ID")
|
||||
or ""
|
||||
).strip() or "_anon"
|
||||
return (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "_anon"
|
||||
|
||||
|
||||
def get_runs_dir() -> str:
|
||||
"""Run Journal 目录:{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/"""
|
||||
return os.path.join(get_data_root(), ".jiangchang", "runs")
|
||||
|
||||
|
||||
def ensure_runs_dir() -> str:
|
||||
path = get_runs_dir()
|
||||
try:
|
||||
os.makedirs(path, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return path
|
||||
|
||||
|
||||
def _looks_like_skills_root(path: str) -> bool:
|
||||
@@ -76,15 +82,13 @@ def get_skills_root() -> str:
|
||||
|
||||
优先级:
|
||||
1) JIANGCHANG_SKILLS_ROOT
|
||||
2) CLAW_SKILLS_ROOT
|
||||
3) JIANGCHANG_APP_ROOT:若 {APP}/skills 存在且像技能根则用之;否则若 APP 下已有技能子目录则 APP 即根
|
||||
4) Windows:默认 D:\\AI\\jiangchang 同上规则
|
||||
5) 其他平台:~/.openclaw/skills
|
||||
2) JIANGCHANG_APP_ROOT:若 {APP}/skills 存在且像技能根则用之;否则若 APP 下已有技能子目录则 APP 即根
|
||||
3) Windows:默认 D:\\AI\\jiangchang 同上规则
|
||||
4) 其他平台:~/.openclaw/skills
|
||||
"""
|
||||
for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"):
|
||||
v = (os.getenv(key) or "").strip()
|
||||
if v:
|
||||
return os.path.normpath(v)
|
||||
v = (os.getenv("JIANGCHANG_SKILLS_ROOT") or "").strip()
|
||||
if v:
|
||||
return os.path.normpath(v)
|
||||
|
||||
app = (os.getenv("JIANGCHANG_APP_ROOT") or "").strip()
|
||||
if sys.platform == "win32" and not app:
|
||||
@@ -110,8 +114,8 @@ def get_sibling_skills_root(skill_scripts_dir: str | None = None) -> str:
|
||||
"""
|
||||
编排子进程调用兄弟技能时用:先根据本技能 scripts 目录推断并列根;若目录下能识别出兄弟技能
|
||||
则用之(开发仓 D:\\OpenClaw 与安装目录 ~/.openclaw/skills 均满足「技能根之上一级 = 并列根」),
|
||||
避免全局 JIANGCHANG_SKILLS_ROOT / CLAW_SKILLS_ROOT 指向与当前检出不一致时错调兄弟进程。
|
||||
推断失败时再读上述环境变量,最后回落 get_skills_root()。
|
||||
避免全局 JIANGCHANG_SKILLS_ROOT 指向与当前检出不一致时错调兄弟进程。
|
||||
推断失败时再读 JIANGCHANG_SKILLS_ROOT,最后回落 get_skills_root()。
|
||||
"""
|
||||
if skill_scripts_dir:
|
||||
scripts = os.path.abspath(skill_scripts_dir)
|
||||
@@ -120,10 +124,9 @@ def get_sibling_skills_root(skill_scripts_dir: str | None = None) -> str:
|
||||
if _looks_like_skills_root(inferred):
|
||||
return os.path.normpath(inferred)
|
||||
|
||||
for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"):
|
||||
v = (os.getenv(key) or "").strip()
|
||||
if v:
|
||||
return os.path.normpath(v)
|
||||
v = (os.getenv("JIANGCHANG_SKILLS_ROOT") or "").strip()
|
||||
if v:
|
||||
return os.path.normpath(v)
|
||||
|
||||
return get_skills_root()
|
||||
|
||||
@@ -139,21 +142,10 @@ def apply_cli_local_defaults() -> None:
|
||||
enabled = v in ("1", "true", "yes", "on")
|
||||
if not enabled:
|
||||
return
|
||||
if not (
|
||||
os.getenv("CLAW_DATA_ROOT")
|
||||
or os.getenv("JIANGCHANG_DATA_ROOT")
|
||||
or ""
|
||||
).strip():
|
||||
default_root = platform_default_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = default_root
|
||||
os.environ.setdefault("CLAW_DATA_ROOT", default_root)
|
||||
if not (
|
||||
os.getenv("CLAW_USER_ID")
|
||||
or os.getenv("JIANGCHANG_USER_ID")
|
||||
or ""
|
||||
).strip():
|
||||
if not (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip():
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = platform_default_data_root()
|
||||
if not (os.getenv("JIANGCHANG_USER_ID") or "").strip():
|
||||
os.environ["JIANGCHANG_USER_ID"] = DEFAULT_LOCAL_USER_ID.strip()
|
||||
os.environ.setdefault("CLAW_USER_ID", DEFAULT_LOCAL_USER_ID.strip())
|
||||
def find_chrome_executable() -> "str | None":
|
||||
"""查找系统已安装的 Chrome 或 Edge 可执行文件路径。
|
||||
|
||||
|
||||
71
src/jiangchang_skill_core/sibling_bridge.py
Normal file
71
src/jiangchang_skill_core/sibling_bridge.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
兄弟技能 CLI 桥:同步 subprocess 调用并列技能 scripts/main.py。
|
||||
|
||||
account-manager 等兄弟技能为同步 CLI,非 async、非线程池。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any, List, Optional, Sequence
|
||||
|
||||
from .runtime_env import get_skills_root
|
||||
from .unified_logging import subprocess_env_with_trace
|
||||
|
||||
|
||||
def get_sibling_main_path(skill_slug: str) -> str:
|
||||
return os.path.join(get_skills_root(), skill_slug, "scripts", "main.py")
|
||||
|
||||
|
||||
def _python_exe() -> str:
|
||||
return sys.executable
|
||||
|
||||
|
||||
def _run_sibling(skill_slug: str, argv: Sequence[str]) -> subprocess.CompletedProcess[str]:
|
||||
main_path = get_sibling_main_path(skill_slug)
|
||||
cmd = [_python_exe(), main_path, *argv]
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
env=subprocess_env_with_trace(),
|
||||
)
|
||||
|
||||
|
||||
def _parse_json_from_stdout(stdout: str) -> Any:
|
||||
for line in reversed((stdout or "").splitlines()):
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("ERROR:"):
|
||||
continue
|
||||
try:
|
||||
return json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def call_sibling_json(skill_slug: str, argv: Sequence[str]) -> dict | None:
|
||||
"""Run sibling CLI; return last JSON object line from stdout, or None."""
|
||||
result = _run_sibling(skill_slug, argv)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
parsed = _parse_json_from_stdout(result.stdout or "")
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
return None
|
||||
|
||||
|
||||
def call_sibling_json_array(skill_slug: str, argv: Sequence[str]) -> list | None:
|
||||
"""Run sibling CLI; return last JSON array line from stdout, or None."""
|
||||
result = _run_sibling(skill_slug, argv)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
parsed = _parse_json_from_stdout(result.stdout or "")
|
||||
if isinstance(parsed, list):
|
||||
return parsed
|
||||
return None
|
||||
218
tests/test_activity_control.py
Normal file
218
tests/test_activity_control.py
Normal file
@@ -0,0 +1,218 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""SRCP v1: control.json, checkpoint, pause/resume/stop, interruptible_sleep."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from jiangchang_skill_core import activity
|
||||
from jiangchang_skill_core.activity import JobStopped
|
||||
from jiangchang_skill_core.runtime_env import get_runs_dir
|
||||
|
||||
|
||||
class _ControlEnvIsolation(unittest.TestCase):
|
||||
_KEYS = (
|
||||
"JIANGCHANG_DATA_ROOT",
|
||||
"JIANGCHANG_USER_ID",
|
||||
"JIANGCHANG_JOB_ID",
|
||||
"JIANGCHANG_RUN_MODE",
|
||||
"JIANGCHANG_CONTROL_DISABLED",
|
||||
"JIANGCHANG_CONTROL_POLL_SEC",
|
||||
)
|
||||
|
||||
def setUp(self) -> None:
|
||||
self._saved = {k: os.environ.get(k) for k in self._KEYS}
|
||||
self._tmps: list[str] = []
|
||||
for k in self._KEYS:
|
||||
os.environ.pop(k, None)
|
||||
activity._reset_for_tests()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
activity._reset_for_tests()
|
||||
for k, v in self._saved.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
for tmp in self._tmps:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
def _tmp_data_root(self) -> str:
|
||||
tmp = tempfile.mkdtemp()
|
||||
self._tmps.append(tmp)
|
||||
return tmp
|
||||
|
||||
def _write_control(self, job_id: str, command: str, *, nonce: int = 1) -> None:
|
||||
path = Path(get_runs_dir()) / f"{job_id}.control.json"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"command": command,
|
||||
"reason": "user",
|
||||
"ts": 1_700_000_000_000,
|
||||
"nonce": nonce,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def _journal_events(self, job_id: str) -> list[dict]:
|
||||
journal = Path(get_runs_dir()) / f"{job_id}.jsonl"
|
||||
if not journal.is_file():
|
||||
return []
|
||||
return [
|
||||
json.loads(line)
|
||||
for line in journal.read_text(encoding="utf-8").strip().splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
|
||||
|
||||
class TestControlProtocol(_ControlEnvIsolation):
|
||||
def test_get_control_path(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "ctrl-path"
|
||||
activity._reset_for_tests()
|
||||
self.assertTrue(activity.get_control_path().endswith("ctrl-path.control.json"))
|
||||
|
||||
def test_stop_raises_job_stopped(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "ctrl-stop"
|
||||
os.environ["JIANGCHANG_CONTROL_POLL_SEC"] = "0.05"
|
||||
activity._reset_for_tests()
|
||||
self._write_control("ctrl-stop", "stop")
|
||||
|
||||
with self.assertRaises(JobStopped):
|
||||
activity.checkpoint()
|
||||
|
||||
events = self._journal_events("ctrl-stop")
|
||||
lifecycle = [e for e in events if e.get("type") == "lifecycle"]
|
||||
self.assertTrue(any(e.get("status") == "stopping" for e in lifecycle))
|
||||
|
||||
def test_emit_stop_before_activity_write(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "ctrl-emit-stop"
|
||||
activity._reset_for_tests()
|
||||
self._write_control("ctrl-emit-stop", "stop")
|
||||
|
||||
with self.assertRaises(JobStopped):
|
||||
activity.emit("不应写入")
|
||||
|
||||
events = self._journal_events("ctrl-emit-stop")
|
||||
self.assertFalse(any(e.get("type") == "activity" for e in events))
|
||||
self.assertTrue(any(e.get("type") == "lifecycle" and e.get("status") == "stopping" for e in events))
|
||||
|
||||
def test_pause_resume_via_emit(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "ctrl-pause"
|
||||
os.environ["JIANGCHANG_CONTROL_POLL_SEC"] = "0.05"
|
||||
activity._reset_for_tests()
|
||||
self._write_control("ctrl-pause", "pause")
|
||||
|
||||
import threading
|
||||
import time as time_mod
|
||||
|
||||
def resume_later() -> None:
|
||||
time_mod.sleep(0.15)
|
||||
self._write_control("ctrl-pause", "resume", nonce=2)
|
||||
|
||||
threading.Thread(target=resume_later, daemon=True).start()
|
||||
activity.emit("步骤一", skill="demo")
|
||||
|
||||
events = self._journal_events("ctrl-pause")
|
||||
statuses = [e.get("status") for e in events if e.get("type") == "lifecycle"]
|
||||
self.assertIn("paused", statuses)
|
||||
self.assertIn("resumed", statuses)
|
||||
activity_events = [e for e in events if e.get("type") == "activity"]
|
||||
self.assertEqual(activity_events[-1]["text"], "步骤一")
|
||||
lifecycle = [e for e in events if e.get("type") == "lifecycle"]
|
||||
self.assertEqual(lifecycle[0].get("step_index"), 1)
|
||||
|
||||
def test_rpa_step_gate_increments_step_index(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "ctrl-rpa"
|
||||
activity._reset_for_tests()
|
||||
|
||||
@activity.rpa_step("打开页面")
|
||||
def open_page() -> None:
|
||||
return None
|
||||
|
||||
open_page()
|
||||
events = self._journal_events("ctrl-rpa")
|
||||
lifecycle_or_activity = [e for e in events if e.get("type") in ("activity", "lifecycle")]
|
||||
self.assertGreaterEqual(len(lifecycle_or_activity), 2)
|
||||
|
||||
def test_job_context_finishes_on_job_stopped(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
activity._reset_for_tests()
|
||||
|
||||
with patch("sys.stdout", io.StringIO()):
|
||||
with self.assertRaises(JobStopped):
|
||||
with activity.job_context(job_id="ctx-stop", skill="demo-skill"):
|
||||
self._write_control("ctx-stop", "stop")
|
||||
activity.checkpoint()
|
||||
|
||||
journal = Path(get_runs_dir()) / "ctx-stop.jsonl"
|
||||
lines = journal.read_text(encoding="utf-8").strip().splitlines()
|
||||
last = json.loads(lines[-1])
|
||||
self.assertEqual(last["type"], "error")
|
||||
self.assertEqual(last.get("error_code"), "JOB_STOPPED")
|
||||
|
||||
def test_interruptible_sleep_honors_stop(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "ctrl-sleep"
|
||||
os.environ["JIANGCHANG_CONTROL_POLL_SEC"] = "0.05"
|
||||
activity._reset_for_tests()
|
||||
self._write_control("ctrl-sleep", "stop")
|
||||
|
||||
async def run() -> None:
|
||||
with self.assertRaises(JobStopped):
|
||||
await activity.interruptible_sleep(5.0)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
def test_interruptible_sleep_sync_honors_pause(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "ctrl-sleep-sync"
|
||||
os.environ["JIANGCHANG_CONTROL_POLL_SEC"] = "0.05"
|
||||
activity._reset_for_tests()
|
||||
self._write_control("ctrl-sleep-sync", "pause")
|
||||
|
||||
def resume_later() -> None:
|
||||
import threading
|
||||
import time
|
||||
|
||||
def _resume() -> None:
|
||||
time.sleep(0.15)
|
||||
self._write_control("ctrl-sleep-sync", "resume", nonce=2)
|
||||
|
||||
threading.Thread(target=_resume, daemon=True).start()
|
||||
|
||||
resume_later()
|
||||
activity.interruptible_sleep_sync(0.4, chunk=0.05)
|
||||
|
||||
events = self._journal_events("ctrl-sleep-sync")
|
||||
statuses = [e.get("status") for e in events if e.get("type") == "lifecycle"]
|
||||
self.assertIn("paused", statuses)
|
||||
self.assertIn("resumed", statuses)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
209
tests/test_activity_emit_finish.py
Normal file
209
tests/test_activity_emit_finish.py
Normal file
@@ -0,0 +1,209 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Skill Activity Stream: journal, flush, job_id, finish stdout, truncation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from jiangchang_skill_core import activity
|
||||
from jiangchang_skill_core.runtime_env import get_runs_dir
|
||||
|
||||
|
||||
class _ActivityEnvIsolation(unittest.TestCase):
|
||||
_KEYS = (
|
||||
"JIANGCHANG_DATA_ROOT",
|
||||
"JIANGCHANG_USER_ID",
|
||||
"JIANGCHANG_JOB_ID",
|
||||
"JIANGCHANG_RUN_MODE",
|
||||
"JIANGCHANG_ACTIVITY_DEBUG",
|
||||
)
|
||||
|
||||
def setUp(self) -> None:
|
||||
self._saved = {k: os.environ.get(k) for k in self._KEYS}
|
||||
self._tmps: list[str] = []
|
||||
for k in self._KEYS:
|
||||
os.environ.pop(k, None)
|
||||
activity._reset_for_tests()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
activity._reset_for_tests()
|
||||
for k, v in self._saved.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
for tmp in self._tmps:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
def _tmp_data_root(self) -> str:
|
||||
tmp = tempfile.mkdtemp()
|
||||
self._tmps.append(tmp)
|
||||
return tmp
|
||||
|
||||
|
||||
class TestActivityJournal(_ActivityEnvIsolation):
|
||||
def test_emit_writes_ndjson_with_meta(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "job-test-1"
|
||||
activity._reset_for_tests()
|
||||
|
||||
activity.emit("步骤一", type="activity", skill="geo-batch")
|
||||
journal = Path(get_runs_dir()) / "job-test-1.jsonl"
|
||||
self.assertTrue(journal.is_file())
|
||||
lines = journal.read_text(encoding="utf-8").strip().splitlines()
|
||||
self.assertGreaterEqual(len(lines), 2)
|
||||
meta = json.loads(lines[0])
|
||||
self.assertEqual(meta["type"], "meta")
|
||||
self.assertEqual(meta["job_id"], "job-test-1")
|
||||
event = json.loads(lines[1])
|
||||
self.assertEqual(event["type"], "activity")
|
||||
self.assertEqual(event["text"], "步骤一")
|
||||
self.assertEqual(event["schema_version"], 1)
|
||||
self.assertEqual(event["skill"], "geo-batch")
|
||||
self.assertIn("ts", event)
|
||||
|
||||
def test_emit_flushes_immediately(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "job-flush"
|
||||
activity._reset_for_tests()
|
||||
activity.emit("flush test")
|
||||
journal = Path(get_runs_dir()) / "job-flush.jsonl"
|
||||
self.assertGreater(journal.stat().st_size, 0)
|
||||
|
||||
def test_job_id_from_env(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "env-job-42"
|
||||
activity._reset_for_tests()
|
||||
self.assertEqual(activity.get_job_id(), "env-job-42")
|
||||
|
||||
def test_job_id_generated_and_cached(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
activity._reset_for_tests()
|
||||
jid1 = activity.get_job_id()
|
||||
jid2 = activity.get_job_id()
|
||||
self.assertEqual(jid1, jid2)
|
||||
self.assertEqual(len(jid1), 32)
|
||||
|
||||
def test_debug_skipped_without_env(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "job-no-debug"
|
||||
activity._reset_for_tests()
|
||||
activity.emit("hidden", type="debug")
|
||||
journal = Path(get_runs_dir()) / "job-no-debug.jsonl"
|
||||
self.assertFalse(journal.exists())
|
||||
|
||||
def test_debug_written_when_enabled(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "job-debug"
|
||||
os.environ["JIANGCHANG_ACTIVITY_DEBUG"] = "1"
|
||||
activity._reset_for_tests()
|
||||
activity.emit("dbg", type="debug")
|
||||
journal = Path(get_runs_dir()) / "job-debug.jsonl"
|
||||
content = journal.read_text(encoding="utf-8")
|
||||
self.assertIn('"type":"debug"', content.replace(" ", ""))
|
||||
|
||||
def test_emit_never_prints_stdout(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "job-quiet"
|
||||
activity._reset_for_tests()
|
||||
buf = io.StringIO()
|
||||
with patch("sys.stdout", buf):
|
||||
activity.emit("silent step")
|
||||
self.assertEqual(buf.getvalue(), "")
|
||||
|
||||
def test_get_run_mode_default_cli(self) -> None:
|
||||
self.assertEqual(activity.get_run_mode(), "cli")
|
||||
|
||||
def test_get_run_mode_job(self) -> None:
|
||||
os.environ["JIANGCHANG_RUN_MODE"] = "job"
|
||||
self.assertEqual(activity.get_run_mode(), "job")
|
||||
|
||||
|
||||
class TestActivityFinish(_ActivityEnvIsolation):
|
||||
def test_finish_stdout_single_line_result(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "job-finish"
|
||||
activity._reset_for_tests()
|
||||
|
||||
buf = io.StringIO()
|
||||
with patch("sys.stdout", buf):
|
||||
activity.finish(status="success", message="ok", skill="geo", generated=3)
|
||||
|
||||
lines = [ln for ln in buf.getvalue().splitlines() if ln.strip()]
|
||||
self.assertEqual(len(lines), 1)
|
||||
result = json.loads(lines[0])
|
||||
self.assertEqual(result["type"], "result")
|
||||
self.assertEqual(result["schema_version"], 1)
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(result["skill"], "geo")
|
||||
self.assertEqual(result["generated"], 3)
|
||||
|
||||
journal = Path(get_runs_dir()) / "job-finish.jsonl"
|
||||
journal_lines = journal.read_text(encoding="utf-8").strip().splitlines()
|
||||
last = json.loads(journal_lines[-1])
|
||||
self.assertEqual(last["type"], "done")
|
||||
self.assertEqual(last["status"], "success")
|
||||
|
||||
def test_finish_failed_writes_error_event(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "job-fail"
|
||||
activity._reset_for_tests()
|
||||
|
||||
with patch("sys.stdout", io.StringIO()):
|
||||
activity.finish(status="failed", message="boom")
|
||||
|
||||
journal = Path(get_runs_dir()) / "job-fail.jsonl"
|
||||
last = json.loads(journal.read_text(encoding="utf-8").strip().splitlines()[-1])
|
||||
self.assertEqual(last["type"], "error")
|
||||
|
||||
def test_finish_truncates_large_result(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "job-trunc"
|
||||
activity._reset_for_tests()
|
||||
|
||||
big = {f"field_{i}": "x" * 200 for i in range(50)}
|
||||
buf = io.StringIO()
|
||||
with patch("sys.stdout", buf):
|
||||
activity.finish(status="success", skill="big", **big)
|
||||
|
||||
line = buf.getvalue().strip()
|
||||
self.assertLessEqual(len(line), 4096)
|
||||
result = json.loads(line)
|
||||
self.assertTrue(result.get("truncated"))
|
||||
|
||||
|
||||
class TestJobContext(_ActivityEnvIsolation):
|
||||
def test_job_context_auto_finish_on_exception(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
activity._reset_for_tests()
|
||||
|
||||
with patch("sys.stdout", io.StringIO()):
|
||||
with self.assertRaises(ValueError):
|
||||
with activity.job_context(job_id="ctx-err", skill="test-skill"):
|
||||
raise ValueError("broken")
|
||||
|
||||
journal = Path(get_runs_dir()) / "ctx-err.jsonl"
|
||||
last = json.loads(journal.read_text(encoding="utf-8").strip().splitlines()[-1])
|
||||
self.assertEqual(last["type"], "error")
|
||||
self.assertIn("broken", last["message"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
120
tests/test_activity_rpa_step_decorator.py
Normal file
120
tests/test_activity_rpa_step_decorator.py
Normal file
@@ -0,0 +1,120 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""rpa_step decorator: sync + async."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from jiangchang_skill_core import activity
|
||||
from jiangchang_skill_core.runtime_env import get_runs_dir
|
||||
|
||||
|
||||
class _DecoratorEnvIsolation(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._saved_root = os.environ.get("JIANGCHANG_DATA_ROOT")
|
||||
self._saved_job = os.environ.get("JIANGCHANG_JOB_ID")
|
||||
self._tmps: list[str] = []
|
||||
os.environ.pop("JIANGCHANG_JOB_ID", None)
|
||||
activity._reset_for_tests()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
activity._reset_for_tests()
|
||||
if self._saved_root is None:
|
||||
os.environ.pop("JIANGCHANG_DATA_ROOT", None)
|
||||
else:
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = self._saved_root
|
||||
if self._saved_job is None:
|
||||
os.environ.pop("JIANGCHANG_JOB_ID", None)
|
||||
else:
|
||||
os.environ["JIANGCHANG_JOB_ID"] = self._saved_job
|
||||
for tmp in self._tmps:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
def _tmp_data_root(self) -> str:
|
||||
tmp = tempfile.mkdtemp()
|
||||
self._tmps.append(tmp)
|
||||
return tmp
|
||||
|
||||
|
||||
class TestRpaStepDecorator(_DecoratorEnvIsolation):
|
||||
def _journal_texts(self, job_id: str) -> list[str]:
|
||||
journal = Path(get_runs_dir()) / f"{job_id}.jsonl"
|
||||
lines = journal.read_text(encoding="utf-8").strip().splitlines()
|
||||
events = [json.loads(ln) for ln in lines if json.loads(ln).get("type") != "meta"]
|
||||
return [e["text"] for e in events]
|
||||
|
||||
def test_sync_success(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "rpa-sync-ok"
|
||||
activity._reset_for_tests()
|
||||
|
||||
@activity.rpa_step("打开浏览器")
|
||||
def open_browser() -> str:
|
||||
return "ok"
|
||||
|
||||
self.assertEqual(open_browser(), "ok")
|
||||
texts = self._journal_texts("rpa-sync-ok")
|
||||
self.assertIn("▶ 打开浏览器", texts)
|
||||
self.assertIn("✓ 打开浏览器", texts)
|
||||
|
||||
def test_sync_failure_emits_warn_and_reraises(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "rpa-sync-fail"
|
||||
activity._reset_for_tests()
|
||||
|
||||
@activity.rpa_step("提交表单")
|
||||
def submit() -> None:
|
||||
raise RuntimeError("timeout")
|
||||
|
||||
with self.assertRaises(RuntimeError):
|
||||
submit()
|
||||
|
||||
journal = Path(get_runs_dir()) / "rpa-sync-fail.jsonl"
|
||||
content = journal.read_text(encoding="utf-8")
|
||||
self.assertIn("timeout", content)
|
||||
self.assertIn('"type":"warn"', content.replace(" ", ""))
|
||||
|
||||
def test_async_success(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "rpa-async-ok"
|
||||
activity._reset_for_tests()
|
||||
|
||||
@activity.rpa_step("异步步骤")
|
||||
async def async_step() -> int:
|
||||
await asyncio.sleep(0)
|
||||
return 42
|
||||
|
||||
result = asyncio.run(async_step())
|
||||
self.assertEqual(result, 42)
|
||||
texts = self._journal_texts("rpa-async-ok")
|
||||
self.assertIn("▶ 异步步骤", texts)
|
||||
self.assertIn("✓ 异步步骤", texts)
|
||||
|
||||
def test_async_failure(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "rpa-async-fail"
|
||||
activity._reset_for_tests()
|
||||
|
||||
@activity.rpa_step()
|
||||
async def fail_step() -> None:
|
||||
await asyncio.sleep(0)
|
||||
raise ValueError("async err")
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
asyncio.run(fail_step())
|
||||
|
||||
journal = Path(get_runs_dir()) / "rpa-async-fail.jsonl"
|
||||
self.assertIn("async err", journal.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -57,7 +57,7 @@ def test_collect_runtime_diagnostics_json_serializable(
|
||||
) -> None:
|
||||
data_root = tmp_path / "data"
|
||||
env = {
|
||||
"CLAW_DATA_ROOT": str(data_root),
|
||||
"JIANGCHANG_DATA_ROOT": str(data_root),
|
||||
"OPENCLAW_RECORD_VIDEO": "0",
|
||||
}
|
||||
media_root = data_root / "shared" / "media-assets"
|
||||
@@ -110,7 +110,7 @@ def test_platform_kit_min_version_none_skips_version_issue(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
env = {"CLAW_DATA_ROOT": str(tmp_path / "data")}
|
||||
env = {"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")}
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
|
||||
lambda *a, **k: ma.MediaAssetsStatus(
|
||||
@@ -144,7 +144,7 @@ def test_platform_kit_missing_records_issue(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
env = {"CLAW_DATA_ROOT": str(tmp_path / "data")}
|
||||
env = {"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")}
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
|
||||
lambda *a, **k: ma.MediaAssetsStatus(
|
||||
@@ -182,7 +182,7 @@ def test_record_video_ffmpeg_and_music_warnings_not_fatal(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
media_root = tmp_path / "shared" / "media-assets"
|
||||
env = {"CLAW_DATA_ROOT": str(tmp_path), "OPENCLAW_RECORD_VIDEO": "1"}
|
||||
env = {"JIANGCHANG_DATA_ROOT": str(tmp_path), "OPENCLAW_RECORD_VIDEO": "1"}
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
|
||||
lambda *a, **k: ma.MediaAssetsStatus(
|
||||
@@ -225,7 +225,7 @@ def test_skill_tree_core_issue_when_skill_root_given(tmp_path: Path) -> None:
|
||||
fake_core.parent.mkdir(parents=True)
|
||||
fake_core.write_text("# vendored\n", encoding="utf-8")
|
||||
|
||||
env = {"CLAW_DATA_ROOT": str(tmp_path / "data")}
|
||||
env = {"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")}
|
||||
with patch.object(jiangchang_skill_core, "__file__", str(fake_core)):
|
||||
diag = collect_runtime_diagnostics(
|
||||
skill_slug="my-skill",
|
||||
@@ -236,11 +236,56 @@ def test_skill_tree_core_issue_when_skill_root_given(tmp_path: Path) -> None:
|
||||
assert "jiangchang_skill_core_loaded_from_skill_tree" in diag.issue_codes()
|
||||
|
||||
|
||||
def _legacy_env_key(suffix: str) -> str:
|
||||
return "CL" + "AW_" + suffix
|
||||
|
||||
|
||||
def test_format_runtime_health_lines_no_legacy_data_root_key(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
env = {
|
||||
"JIANGCHANG_DATA_ROOT": str(tmp_path / "data"),
|
||||
_legacy_env_key("DATA_ROOT"): str(tmp_path / "legacy-only"),
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
|
||||
lambda *a, **k: ma.MediaAssetsStatus(
|
||||
root=tmp_path,
|
||||
exists=False,
|
||||
ready=False,
|
||||
source="local",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics.probe_background_music",
|
||||
lambda *a, **k: {
|
||||
"music_root": None,
|
||||
"mp3_count": 0,
|
||||
"usable_count": 0,
|
||||
"sample_path": None,
|
||||
"issue": None,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics._platform_kit_version",
|
||||
lambda: "1.0.17",
|
||||
)
|
||||
|
||||
diag = collect_runtime_diagnostics(skill_slug="my-skill", env=env)
|
||||
payload = runtime_diagnostics_dict(diag)
|
||||
text = "\n".join(format_runtime_health_lines(diag))
|
||||
legacy_key = _legacy_env_key("DATA_ROOT")
|
||||
assert legacy_key not in payload
|
||||
assert legacy_key not in text
|
||||
assert payload["resolved_data_root"] == str(tmp_path / "data")
|
||||
|
||||
|
||||
def test_format_runtime_health_lines_core_fields(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
env = {"CLAW_DATA_ROOT": str(tmp_path / "data")}
|
||||
env = {"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")}
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
|
||||
lambda *a, **k: ma.MediaAssetsStatus(
|
||||
@@ -301,7 +346,6 @@ def test_runtime_diagnostics_frozen_dataclass() -> None:
|
||||
platform_kit_min_version=None,
|
||||
platform_kit_version_ok=None,
|
||||
jiangchang_skill_core_file=None,
|
||||
claw_data_root=None,
|
||||
jiangchang_data_root=None,
|
||||
resolved_data_root="/tmp",
|
||||
media_assets_root="/tmp/media",
|
||||
@@ -326,7 +370,6 @@ def test_format_runtime_health_lines_issue_separator_ascii() -> None:
|
||||
platform_kit_min_version=None,
|
||||
platform_kit_version_ok=None,
|
||||
jiangchang_skill_core_file=None,
|
||||
claw_data_root=None,
|
||||
jiangchang_data_root=None,
|
||||
resolved_data_root="/tmp",
|
||||
media_assets_root="/tmp/media",
|
||||
|
||||
204
tests/test_runtime_env.py
Normal file
204
tests/test_runtime_env.py
Normal file
@@ -0,0 +1,204 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""runtime_env:JIANGCHANG_* 路径契约与兄弟技能根解析。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from jiangchang_skill_core import config, unified_logging as ul
|
||||
from jiangchang_skill_core.runtime_diagnostics import (
|
||||
collect_runtime_diagnostics,
|
||||
format_runtime_health_lines,
|
||||
runtime_diagnostics_dict,
|
||||
)
|
||||
from jiangchang_skill_core.runtime_env import (
|
||||
apply_cli_local_defaults,
|
||||
get_data_root,
|
||||
get_sibling_skills_root,
|
||||
get_skills_root,
|
||||
get_user_id,
|
||||
platform_default_data_root,
|
||||
)
|
||||
|
||||
|
||||
def _legacy_env_key(suffix: str) -> str:
|
||||
return "CL" + "AW_" + suffix
|
||||
|
||||
|
||||
class _EnvIsolation(unittest.TestCase):
|
||||
_KEYS = (
|
||||
"JIANGCHANG_DATA_ROOT",
|
||||
"JIANGCHANG_USER_ID",
|
||||
"JIANGCHANG_SKILLS_ROOT",
|
||||
"JIANGCHANG_APP_ROOT",
|
||||
_legacy_env_key("DATA_ROOT"),
|
||||
_legacy_env_key("USER_ID"),
|
||||
_legacy_env_key("SKILLS_ROOT"),
|
||||
"JIANGCHANG_CLI_LOCAL_DEV",
|
||||
)
|
||||
|
||||
def setUp(self) -> None:
|
||||
self._saved = {k: os.environ.get(k) for k in self._KEYS}
|
||||
for k in self._KEYS:
|
||||
os.environ.pop(k, None)
|
||||
config.reset_cache()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
for k, v in self._saved.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
config.reset_cache()
|
||||
|
||||
|
||||
class TestGetDataRoot(_EnvIsolation):
|
||||
def test_jiangchang_data_root_only(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
self.assertEqual(get_data_root(), tmp)
|
||||
|
||||
def test_platform_default_when_unset(self) -> None:
|
||||
self.assertEqual(get_data_root(), platform_default_data_root())
|
||||
|
||||
def test_legacy_claw_data_root_ignored(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as claw_tmp, tempfile.TemporaryDirectory() as jc_tmp:
|
||||
os.environ[_legacy_env_key("DATA_ROOT")] = claw_tmp
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = jc_tmp
|
||||
self.assertEqual(get_data_root(), jc_tmp)
|
||||
|
||||
def test_legacy_claw_data_root_alone_ignored(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as claw_tmp:
|
||||
os.environ[_legacy_env_key("DATA_ROOT")] = claw_tmp
|
||||
self.assertEqual(get_data_root(), platform_default_data_root())
|
||||
|
||||
|
||||
class TestGetUserId(_EnvIsolation):
|
||||
def test_jiangchang_user_id_only(self) -> None:
|
||||
os.environ["JIANGCHANG_USER_ID"] = "user-42"
|
||||
self.assertEqual(get_user_id(), "user-42")
|
||||
|
||||
def test_anon_when_unset(self) -> None:
|
||||
self.assertEqual(get_user_id(), "_anon")
|
||||
|
||||
def test_legacy_claw_user_id_ignored(self) -> None:
|
||||
os.environ[_legacy_env_key("USER_ID")] = "claw-only"
|
||||
os.environ["JIANGCHANG_USER_ID"] = "jc-user"
|
||||
self.assertEqual(get_user_id(), "jc-user")
|
||||
|
||||
def test_legacy_claw_user_id_alone_ignored(self) -> None:
|
||||
os.environ[_legacy_env_key("USER_ID")] = "claw-only"
|
||||
self.assertEqual(get_user_id(), "_anon")
|
||||
|
||||
|
||||
class TestApplyCliLocalDefaults(_EnvIsolation):
|
||||
def test_sets_jiangchang_only(self) -> None:
|
||||
with patch("jiangchang_skill_core.runtime_env.CLI_LOCAL_DEV_ENABLED", True):
|
||||
apply_cli_local_defaults()
|
||||
self.assertTrue(os.environ.get("JIANGCHANG_DATA_ROOT"))
|
||||
self.assertTrue(os.environ.get("JIANGCHANG_USER_ID"))
|
||||
self.assertNotIn(_legacy_env_key("DATA_ROOT"), os.environ)
|
||||
self.assertNotIn(_legacy_env_key("USER_ID"), os.environ)
|
||||
|
||||
def test_does_not_override_existing(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_USER_ID"] = "existing"
|
||||
with patch("jiangchang_skill_core.runtime_env.CLI_LOCAL_DEV_ENABLED", True):
|
||||
apply_cli_local_defaults()
|
||||
self.assertEqual(os.environ["JIANGCHANG_DATA_ROOT"], tmp)
|
||||
self.assertEqual(os.environ["JIANGCHANG_USER_ID"], "existing")
|
||||
|
||||
|
||||
class TestGetSkillsRoot(_EnvIsolation):
|
||||
def test_jiangchang_skills_root_override(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.environ["JIANGCHANG_SKILLS_ROOT"] = tmp
|
||||
self.assertEqual(get_skills_root(), os.path.normpath(tmp))
|
||||
|
||||
def test_legacy_claw_skills_root_ignored(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as claw_tmp, tempfile.TemporaryDirectory() as jc_tmp:
|
||||
os.environ[_legacy_env_key("SKILLS_ROOT")] = claw_tmp
|
||||
os.environ["JIANGCHANG_SKILLS_ROOT"] = jc_tmp
|
||||
self.assertEqual(get_skills_root(), os.path.normpath(jc_tmp))
|
||||
|
||||
def test_legacy_claw_skills_root_alone_ignored(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as claw_tmp:
|
||||
os.environ[_legacy_env_key("SKILLS_ROOT")] = claw_tmp
|
||||
root = get_skills_root()
|
||||
self.assertNotEqual(root, os.path.normpath(claw_tmp))
|
||||
|
||||
|
||||
class TestGetSiblingSkillsRoot(_EnvIsolation):
|
||||
def _make_skills_tree(self, base: Path) -> Path:
|
||||
skills_root = base / "skills"
|
||||
(skills_root / "account-manager").mkdir(parents=True)
|
||||
(skills_root / "receive-order" / "scripts").mkdir(parents=True)
|
||||
return skills_root
|
||||
|
||||
def test_inference_preferred_over_env(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tree_tmp, tempfile.TemporaryDirectory() as env_tmp:
|
||||
skills_root = self._make_skills_tree(Path(tree_tmp))
|
||||
scripts_dir = str(skills_root / "receive-order" / "scripts")
|
||||
os.environ["JIANGCHANG_SKILLS_ROOT"] = env_tmp
|
||||
result = get_sibling_skills_root(scripts_dir)
|
||||
self.assertEqual(result, os.path.normpath(str(skills_root)))
|
||||
|
||||
def test_env_override_when_inference_fails(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as env_tmp:
|
||||
os.environ["JIANGCHANG_SKILLS_ROOT"] = env_tmp
|
||||
result = get_sibling_skills_root("/nonexistent/skill/scripts")
|
||||
self.assertEqual(result, os.path.normpath(env_tmp))
|
||||
|
||||
def test_legacy_claw_skills_root_ignored_for_sibling(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as claw_tmp, tempfile.TemporaryDirectory() as jc_tmp:
|
||||
os.environ[_legacy_env_key("SKILLS_ROOT")] = claw_tmp
|
||||
os.environ["JIANGCHANG_SKILLS_ROOT"] = jc_tmp
|
||||
result = get_sibling_skills_root("/nonexistent/skill/scripts")
|
||||
self.assertEqual(result, os.path.normpath(jc_tmp))
|
||||
|
||||
|
||||
class TestConfigAndLoggingPaths(_EnvIsolation):
|
||||
def test_config_env_file_under_user_isolated_dir(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_USER_ID"] = "u-test"
|
||||
example = os.path.join(tmp, ".env.example")
|
||||
with open(example, "w", encoding="utf-8") as f:
|
||||
f.write("FOO=bar\n")
|
||||
dest = config.ensure_env_file("my-skill", example)
|
||||
expected_prefix = os.path.join(tmp, "u-test", "my-skill")
|
||||
self.assertTrue(os.path.normpath(dest).startswith(os.path.normpath(expected_prefix)))
|
||||
|
||||
def test_unified_logging_path_under_user_dir(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_USER_ID"] = "log-user"
|
||||
log_dir = ul.get_unified_logs_dir()
|
||||
self.assertTrue(
|
||||
os.path.normpath(log_dir).startswith(
|
||||
os.path.normpath(os.path.join(tmp, "log-user", "logs"))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestDiagnosticsNoClawFields(_EnvIsolation):
|
||||
def test_diagnostics_json_and_text_exclude_claw(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
diag = collect_runtime_diagnostics(skill_slug="x", env=dict(os.environ))
|
||||
payload = runtime_diagnostics_dict(diag)
|
||||
text = "\n".join(format_runtime_health_lines(diag))
|
||||
legacy_data_root_key = _legacy_env_key("DATA_ROOT")
|
||||
self.assertNotIn(legacy_data_root_key, payload)
|
||||
self.assertNotIn(legacy_data_root_key, text)
|
||||
self.assertIn("JIANGCHANG_DATA_ROOT", payload)
|
||||
self.assertEqual(payload["resolved_data_root"], tmp)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
72
tests/test_sibling_bridge.py
Normal file
72
tests/test_sibling_bridge.py
Normal file
@@ -0,0 +1,72 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""sibling_bridge: mock subprocess sibling CLI calls."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from jiangchang_skill_core.sibling_bridge import (
|
||||
call_sibling_json,
|
||||
call_sibling_json_array,
|
||||
get_sibling_main_path,
|
||||
)
|
||||
|
||||
|
||||
class TestSiblingBridge(unittest.TestCase):
|
||||
def test_get_sibling_main_path(self) -> None:
|
||||
with patch.dict(os.environ, {"JIANGCHANG_SKILLS_ROOT": r"D:\skills"}):
|
||||
path = get_sibling_main_path("account-manager")
|
||||
self.assertTrue(path.replace("\\", "/").endswith("account-manager/scripts/main.py"))
|
||||
|
||||
@patch("jiangchang_skill_core.sibling_bridge.subprocess.run")
|
||||
def test_call_sibling_json_success(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = subprocess.CompletedProcess(
|
||||
args=[],
|
||||
returncode=0,
|
||||
stdout='log line\n{"id": 1, "name": "test"}\n',
|
||||
stderr="",
|
||||
)
|
||||
result = call_sibling_json("account-manager", ["list", "all"])
|
||||
self.assertEqual(result, {"id": 1, "name": "test"})
|
||||
cmd = mock_run.call_args[0][0]
|
||||
self.assertIn("account-manager", cmd[1])
|
||||
self.assertIn("list", cmd)
|
||||
self.assertTrue(mock_run.call_args[1]["capture_output"])
|
||||
|
||||
@patch("jiangchang_skill_core.sibling_bridge.subprocess.run")
|
||||
def test_call_sibling_json_nonzero_exit(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = subprocess.CompletedProcess(
|
||||
args=[], returncode=1, stdout="", stderr="ERROR:fail"
|
||||
)
|
||||
self.assertIsNone(call_sibling_json("content-manager", ["get", "x"]))
|
||||
|
||||
@patch("jiangchang_skill_core.sibling_bridge.subprocess.run")
|
||||
def test_call_sibling_json_array(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = subprocess.CompletedProcess(
|
||||
args=[],
|
||||
returncode=0,
|
||||
stdout=json.dumps([{"a": 1}, {"b": 2}]),
|
||||
stderr="",
|
||||
)
|
||||
result = call_sibling_json_array("account-manager", ["list", "all"])
|
||||
self.assertEqual(result, [{"a": 1}, {"b": 2}])
|
||||
|
||||
@patch("jiangchang_skill_core.sibling_bridge.subprocess.run")
|
||||
def test_uses_subprocess_env_with_trace(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout='{"ok":true}', stderr=""
|
||||
)
|
||||
with patch(
|
||||
"jiangchang_skill_core.sibling_bridge.subprocess_env_with_trace",
|
||||
return_value={"JIANGCHANG_TRACE_ID": "abc123"},
|
||||
) as mock_env:
|
||||
call_sibling_json("api-key-vault", ["get", "key"])
|
||||
self.assertEqual(mock_run.call_args[1]["env"], {"JIANGCHANG_TRACE_ID": "abc123"})
|
||||
mock_env.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
113
tests/test_video_session_activity_integration.py
Normal file
113
tests/test_video_session_activity_integration.py
Normal file
@@ -0,0 +1,113 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""RpaVideoSession.add_step integrates with activity.emit."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from jiangchang_skill_core import activity, config
|
||||
from jiangchang_skill_core.rpa.video_session import RpaVideoSession
|
||||
from jiangchang_skill_core.runtime_env import get_runs_dir
|
||||
|
||||
|
||||
class TestVideoSessionActivityIntegration(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
config.reset_cache()
|
||||
self._saved_root = os.environ.get("JIANGCHANG_DATA_ROOT")
|
||||
self._saved_job = os.environ.get("JIANGCHANG_JOB_ID")
|
||||
self._saved_video = os.environ.get("OPENCLAW_RECORD_VIDEO")
|
||||
self._tmps: list[str] = []
|
||||
activity._reset_for_tests()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
activity._reset_for_tests()
|
||||
if self._saved_root is None:
|
||||
os.environ.pop("JIANGCHANG_DATA_ROOT", None)
|
||||
else:
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = self._saved_root
|
||||
if self._saved_job is None:
|
||||
os.environ.pop("JIANGCHANG_JOB_ID", None)
|
||||
else:
|
||||
os.environ["JIANGCHANG_JOB_ID"] = self._saved_job
|
||||
if self._saved_video is None:
|
||||
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
|
||||
else:
|
||||
os.environ["OPENCLAW_RECORD_VIDEO"] = self._saved_video
|
||||
config.reset_cache()
|
||||
for tmp in self._tmps:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
def _tmp_data_root(self) -> str:
|
||||
tmp = tempfile.mkdtemp()
|
||||
self._tmps.append(tmp)
|
||||
return tmp
|
||||
|
||||
def test_add_step_emits_activity_when_disabled(self) -> None:
|
||||
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "video-act-1"
|
||||
activity._reset_for_tests()
|
||||
config.reset_cache()
|
||||
|
||||
session = RpaVideoSession(
|
||||
skill_slug="publish-toutiao",
|
||||
skill_data_dir=tmp,
|
||||
batch_id="batch1",
|
||||
)
|
||||
self.assertFalse(session.enabled)
|
||||
session.add_step("打开浏览器")
|
||||
|
||||
journal = Path(get_runs_dir()) / "video-act-1.jsonl"
|
||||
self.assertTrue(journal.is_file())
|
||||
content = journal.read_text(encoding="utf-8")
|
||||
self.assertIn("打开浏览器", content)
|
||||
self.assertIn("rpa.video", content)
|
||||
self.assertIn("publish-toutiao", content)
|
||||
|
||||
def test_emit_activity_false_skips_journal(self) -> None:
|
||||
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "video-act-off"
|
||||
activity._reset_for_tests()
|
||||
|
||||
session = RpaVideoSession(
|
||||
skill_slug="publish-toutiao",
|
||||
skill_data_dir=tmp,
|
||||
batch_id="batch2",
|
||||
emit_activity=False,
|
||||
)
|
||||
session.add_step("不应写入")
|
||||
journal = Path(get_runs_dir()) / "video-act-off.jsonl"
|
||||
self.assertFalse(journal.exists())
|
||||
|
||||
def test_add_step_writes_both_journal_and_subtitle_steps(self) -> None:
|
||||
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "video-act-both"
|
||||
activity._reset_for_tests()
|
||||
config.reset_cache()
|
||||
|
||||
session = RpaVideoSession(
|
||||
skill_slug="receive-order",
|
||||
skill_data_dir=tmp,
|
||||
batch_id="batch3",
|
||||
)
|
||||
session.add_step("录制步骤")
|
||||
self.assertEqual(len(session._steps), 1)
|
||||
|
||||
journal = Path(get_runs_dir()) / "video-act-both.jsonl"
|
||||
lines = [json.loads(ln) for ln in journal.read_text(encoding="utf-8").splitlines()]
|
||||
activity_events = [e for e in lines if e.get("type") == "activity"]
|
||||
self.assertEqual(len(activity_events), 1)
|
||||
self.assertEqual(activity_events[0]["text"], "录制步骤")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
145
uv.lock
generated
145
uv.lock
generated
@@ -116,6 +116,92 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.5.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/a1/1f7f0c555f5858fd2906fe9f7b0a3554fddb85cb70df7a6aaec41dc292c2/greenlet-3.5.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c180d22d325fb613956b443c3c6f4406eb70e6defc70d3974da2a7b59e06f48c", size = 285838, upload-time = "2026-06-26T18:21:05.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/29/be9f43ed61677a5759b38c8a9389248133c8c731bbfc0574ecdff66c99fc/greenlet-3.5.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483d08c11181c83a6ce1a7a61df0f624a208ec40817a3bb2302714592eee4f04", size = 602342, upload-time = "2026-06-26T19:07:06.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/42/ba41c97ec36aa4b3ec25e5aa691d79561254805fad7f2f826dd6770587e2/greenlet-3.5.3-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1dae6e0091eae084317e411f047f0b7cb241c6db570f7c45fd6b900a274914ce", size = 615541, upload-time = "2026-06-26T19:10:04.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/8c/231ca675b0df779816950ca66b40b1fa14dbff4a0ed9814a9a29ec399140/greenlet-3.5.3-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0f6ff50ff8dbd51fae9b37f4101648b04ea0df19b3f50ab2beb5061e7716a5c8", size = 622473, upload-time = "2026-06-26T19:24:12.786Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/c7/28747042e1df8a9cd120a1ebe15529fc4be3b486e13e8d551ff307a82412/greenlet-3.5.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcd2d72ccd70a1ec68ba6ef93e7fbb4420ef9997dabc7010d893bd4015e0bec", size = 615675, upload-time = "2026-06-26T18:32:14.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/fe/dd97c483a3ff82849196ccd07851600edd3ac9de74669ca8a6022ada9ea1/greenlet-3.5.3-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:37bf9c538f5ae6e63d643f88dec37c0c83bdf0e2ebc62961dedcf458822f7b71", size = 418421, upload-time = "2026-06-26T19:25:34.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/a8/b85525a6c8fba9f009a5f7c8df1545de8fb0f0bf3e0179194ef4e500317f/greenlet-3.5.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:73f152c895e09907e0dbe24f6c2db37beb085cd63db91c3825a0fcd0064124a8", size = 1575057, upload-time = "2026-06-26T19:09:00.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/79/fb76edb218fe6735ab0edeba176c7ab80df9618f7c02ce4208979f3ae7db/greenlet-3.5.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8bdb43e1a1d1873721acab2be99c5befd4d2044ddfd52e4d610801019880a702", size = 1641692, upload-time = "2026-06-26T18:31:41.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/79/86fe3ee50ed55d9b3907eecd3208b5c3fe8a79515519aae98b4753c3fa1d/greenlet-3.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:0909f9355a9f24845d3299f3112e266a06afb68302041989fd26bd68894933db", size = 238742, upload-time = "2026-06-26T18:20:40.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/58/5404031044f55afad7aad1aff8be3f22b1bed03e237cfeabbc7e5c8cfde0/greenlet-3.5.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:aca9b4ce85b152b5524ef7d88170efdff80dc0032aa8b75f9aaf7f3479ea95b4", size = 287424, upload-time = "2026-06-26T18:20:31.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/bf/1c65e9b94a54d547068fa5b5a8a06f221f3316b48908e08668d29c77cb50/greenlet-3.5.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f71be4920368fe1fabeeaa53d1e3548337e2b223d9565f8ad5e392a75ba23fc", size = 606523, upload-time = "2026-06-26T19:07:08.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/c7/b66baacc95775ad511287acb0137b95574a9ce5491902372b7564799d790/greenlet-3.5.3-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d77e67f65f98449e3fb83f795b5d0a8437aead2f874ca89c96576caf4be3af6", size = 618315, upload-time = "2026-06-26T19:10:06.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/a0/68afd1ebad40db87dac0a28ffa120726b98bf9c7c40c481b0f63c105d298/greenlet-3.5.3-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e18619ba655ac05d78d80fc83cac4ba892bd6927b99e3b8237aee861aaacc8bb", size = 626155, upload-time = "2026-06-26T19:24:14.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/2b/28ed29463522fdbe4c15b1f63922041626a7478316b34ab4adda3f0a4aba/greenlet-3.5.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8540f1e6205bd13ca0ce685581037219ca54a1b41a0a15d228c6c9b8ad5903d7", size = 617381, upload-time = "2026-06-26T18:32:16.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/7f/e327d912239ec4b3b49999e3967389bcf1ee8722b9ee9194d2752ecd558a/greenlet-3.5.3-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:d27c0c653a60d9535f690226474a5cc1036a8b0d7b57504d1c4f89c44a07a80c", size = 421083, upload-time = "2026-06-26T19:25:35.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/7b/ad04e9d1337fc04965dc9fc616b6a72cb65a24b800a014c011ec812f5489/greenlet-3.5.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ef56fe650f50575bf843acde967b9c567687f3c22340941a899b7bc56e956a8", size = 1577771, upload-time = "2026-06-26T19:09:01.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/33/6c87ab7ba663f70ca21f3022aad1ffe56d3f3e0521e836c2415e13abcc3c/greenlet-3.5.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5121af01cf911e70056c00d4b46d5e9b5d1415550038573d744138bacb59e6b8", size = 1644048, upload-time = "2026-06-26T18:31:42.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/35/f0d8ee998b422cf8693b270f098e55d8d4ec8006b061b333f54f177d28d9/greenlet-3.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:0f41e4a05a3c0cb31b17023eff28dd111e1d16bf7d7d00406cd7df23f31398a7", size = 239137, upload-time = "2026-06-26T18:23:21.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/96/b9820295576ef18c9edc404f10e260ae7215ceaf3781a54b720ed2627862/greenlet-3.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:ec6f1af59f6b5f3fc9678e2ea062d8377d22ac644f7844cb7a292910cf12ff44", size = 237630, upload-time = "2026-06-26T18:24:00.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/fb/d97dc261209c80744b7c8132693a30d70ec6e7315e632cb0a10b3fec94dd/greenlet-3.5.3-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5795cd1101371140551c645f2d408b8d3c01a5a29cf8a9bce6e759c983682d23", size = 622351, upload-time = "2026-06-26T19:24:16.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/ac/e5fee13cbbd0e8de312d9a146584b8a51891c68847330ef9dc8b5109d23f/greenlet-3.5.3-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:af4923b3096e26a36d7e9cf24ab88083a20f97d191e3b97f253731ce9b41b28c", size = 425395, upload-time = "2026-06-26T19:25:37.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/61/2f5b1adf256d039f5dab8005de8d3d7ad2b0070a3219c0e036b3fbfeb440/greenlet-3.5.3-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ad04dd75458c6300b047c61b8639092433d205a25a14e310d6582a480efcca1", size = 625580, upload-time = "2026-06-26T19:24:18.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d9/ab7fc9e543e44d6879b0a6ef9a4b2188940fd180cc65d6f646883ddf7201/greenlet-3.5.3-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:afaabdd554cd7ae9bbb3ca070b0d7fdfd207dbf1d16865f7233837709d354bda", size = 427933, upload-time = "2026-06-26T19:25:38.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4", size = 238141, upload-time = "2026-06-26T18:22:48.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/a9/73fa62893d5b84b4205544e6b673c654cc43aa5b9899bac00f04d64af73d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8d19fe6c39ebff9259f07bcc685d3290f8fa4ea2278e51dd0008e4d6b0f2d814", size = 670657, upload-time = "2026-06-26T19:24:19.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/7e/2ffce64929fb3cab7b65d5a0b20aaf9764e227681d731b041077fc9a525a/greenlet-3.5.3-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:962c5df2db8cb446da51edf1ca5296c389d93b99c9d8aa2ee4c7d0d8f1218260", size = 473497, upload-time = "2026-06-26T19:25:39.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/06/68d0983e79e02138f64b4d303c500c27ddb48e5e77f3debb80888a921eae/greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605", size = 239549, upload-time = "2026-06-26T18:22:42.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/73/8faec206b851c22b1733545fda900829a1f3f5b1c78ae7e0fb3dba57d9f4/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b897d97759425953f69a9c0fac67f8fe333ec0ce7377ef186fb2b0c3ad5e354d", size = 659582, upload-time = "2026-06-26T19:24:21.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/55/50c19e49f8045834ada71ef12f8ad048eba8517c6aa41161bed676328fae/greenlet-3.5.3-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:3236754d423955ea08e9bb5f6c04a7895f9e22c290b66aa7653fcb922d839eb0", size = 491037, upload-time = "2026-06-26T19:25:40.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc", size = 288860, upload-time = "2026-06-26T18:22:48.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47", size = 659747, upload-time = "2026-06-26T19:07:15.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81", size = 670419, upload-time = "2026-06-26T19:10:12.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/aa/952cf28c2ff949a8c971134fb43854dd7eaa737218723aaef758f8c9aead/greenlet-3.5.3-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cefa9cef4b371f9844c6053db71f1138bc6807bab1578b0dae5149c1f1141357", size = 674261, upload-time = "2026-06-26T19:24:22.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d", size = 668787, upload-time = "2026-06-26T18:32:24.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/f2/b00d6f5e63e531a93562b2ec1a4c320fbee91f580fc42e6417af69d706e5/greenlet-3.5.3-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:6219b6d04dbf6ba6084d77dc609e8473060dc55f759cbf626d512122781fa128", size = 480322, upload-time = "2026-06-26T19:25:41.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34", size = 1626305, upload-time = "2026-06-26T19:09:08.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b", size = 1688631, upload-time = "2026-06-26T18:31:51.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930", size = 241027, upload-time = "2026-06-26T18:22:38.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/e8/883785b44c5780ed71e83d3e4437e710470be17a2e181e8b601e2da0dc4a/greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227", size = 240085, upload-time = "2026-06-26T18:23:54.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c", size = 297221, upload-time = "2026-06-26T18:23:27.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f", size = 657221, upload-time = "2026-06-26T19:07:16.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2", size = 663226, upload-time = "2026-06-26T19:10:13.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/39/0e0938a75115b939d42733a2a12e1d349653c9531fe6fe563e8a681f04e6/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d192579ed281051396dddd7f7754dac6259e6b1fb26378c87b66622f8e3f91", size = 663706, upload-time = "2026-06-26T19:24:24.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608", size = 660802, upload-time = "2026-06-26T18:32:26.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/41/35d1c678cdb3c3b9e6bee691728e563cfb294202b23c7a4c3c2ccc343589/greenlet-3.5.3-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:4399eb8d041f20b68d943918bc55502a93d6fdc0a37c14da7881c04139acee9d", size = 498803, upload-time = "2026-06-26T19:25:43.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb", size = 1622157, upload-time = "2026-06-26T19:09:09.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16", size = 1681159, upload-time = "2026-06-26T18:31:52.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf", size = 244007, upload-time = "2026-06-26T18:22:04.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/0d/ca7d15afbdc397e3401134c9e1800d51d12b829661786187a4ad08fe484f/greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31", size = 242586, upload-time = "2026-06-26T18:23:37.93Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.13"
|
||||
@@ -127,14 +213,60 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "jiangchang-platform-kit"
|
||||
version = "0.1.0"
|
||||
version = "1.0.15"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "mss" },
|
||||
{ name = "playwright" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "requests", specifier = ">=2.31.0" }]
|
||||
requires-dist = [
|
||||
{ name = "mss", specifier = ">=9.0.1" },
|
||||
{ name = "playwright", specifier = ">=1.42.0" },
|
||||
{ name = "requests", specifier = ">=2.31.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mss"
|
||||
version = "10.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e5/5d/eee782a6d674f562c946ae6a026f4c595ea2b7b031f290bf9fbf60da09b5/mss-10.2.0.tar.gz", hash = "sha256:ab271860775545e62f29d7b11f82f279ac1048f5bbdd26cfad84830208dbd393", size = 200317, upload-time = "2026-04-23T10:44:57.305Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/c3/313e14f245c79b4c05bd0f3a84a4813aa26fa10f8993aebd91d04c5fad3f/mss-10.2.0-py3-none-any.whl", hash = "sha256:e79f428899280e7e64e38365b5bfed683851ebea807eeaeadaf06eb8e0d67197", size = 67106, upload-time = "2026-04-23T10:44:56.266Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "playwright"
|
||||
version = "1.60.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "greenlet" },
|
||||
{ name = "pyee" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/21/f0/832bd9677194908da118064eef20082f2791e3d18215cc6d9391ee2c5a67/playwright-1.60.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:6a8cd0fec171fb3089e95e898c8bc8a6f35dea0b78b399e12fcc19427e91b1d7", size = 43474635, upload-time = "2026-05-18T12:00:31.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/7b/e1d32ae8a3ed937ec2be3721c5f728b13d731a0b7c6442e0b3bec5094ac0/playwright-1.60.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:39b5420ba6145045b69ced4c5c47d4d9fe5bddfc8ff816c518913afcb25ec7a5", size = 42261327, upload-time = "2026-05-18T12:00:35.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/bc/23de499ded6411c188a20c5a0dea6f0cd4ed5d2b3cc6042a5dbd3ed609aa/playwright-1.60.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:2581d0e6a3392c71f91b27460c7fd093356818dc430f48153896c8aeeaef7705", size = 43474636, upload-time = "2026-05-18T12:00:39.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/7b/1d679f4fced4ea94efadd17103856d8c565384f68382a1681264e46f5925/playwright-1.60.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:1c2bfae7884fb3fb05b853290eab8f343d524e5016f2f1def702acbbdf14c93e", size = 47467220, upload-time = "2026-05-18T12:00:43.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/c2/1528d267d4442bd2c6b8eaeab819dd52c2030bf80e89293f0ba1f687473b/playwright-1.60.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43e66564125ee31b07a58cefb21e256d62d67d8d1713e6858df7a3019d8ed353", size = 47154856, upload-time = "2026-05-18T12:00:46.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/4e/b008b6440a7a1624378041da94829956d4b8f7ab9ef5aad22d0dc3f2e26d/playwright-1.60.0-py3-none-win32.whl", hash = "sha256:ec94e416ea320711e0ad4bf185dcbf41833672961e90773e1885255d7db7b7e7", size = 37902157, upload-time = "2026-05-18T12:00:50.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/f0/0541524133104f9cc20bf900870ff4a736b76a23483f3a55295ddfa58409/playwright-1.60.0-py3-none-win_amd64.whl", hash = "sha256:9566821ce6030a1f9e7146a24e19355ab0d98805fd0f9be50bb3d8fef1750c02", size = 37902159, upload-time = "2026-05-18T12:00:53.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/c8/210f282d278e4709cdd71b12a31af45a30a22ab3207b387e29b37e478713/playwright-1.60.0-py3-none-win_arm64.whl", hash = "sha256:6e4f6700a4c2250efff8e690a81d66e3855754fb587b6b87cf5c784014f91537", size = 34037981, upload-time = "2026-05-18T12:00:57.584Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyee"
|
||||
version = "13.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
@@ -151,6 +283,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
|
||||
Reference in New Issue
Block a user