4 Commits

Author SHA1 Message Date
1c51139883 add skill activity stream and job runtime foundation (v1.1.0)
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 17s
Introduce SAS v1 emit/finish/journal API, sibling CLI bridge, RPA video session integration, and tests for the host Job Runner activity timeline.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 19:12:51 +08:00
1632828372 统一运行时环境变量契约,移除 CLAW_* 别名 (v1.0.17)
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 19s
2026-06-30 14:41:38 +08:00
f7fc33fa4a fix(config): unify RPA runtime reads through config.get* (v1.0.16)
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 21s
Route OPENCLAW_BROWSER_HEADLESS, OPENCLAW_PLAYWRIGHT_STEALTH, OPENCLAW_ARTIFACTS_ON_FAILURE, and OPENCLAW_RECORD_VIDEO (health diagnostics) through jiangchang_skill_core.config.get_bool so user .env and .env.example share the same three-tier priority as the rest of the skill stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 10:21:32 +08:00
5a0b93448c 升级版本到1.0.15
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 25s
2026-06-27 14:53:59 +08:00
19 changed files with 1483 additions and 69 deletions

View File

@@ -12,7 +12,7 @@ pip install jiangchang-platform-kit --index-url https://git.jc2009.com/api/packa
## 3. 包含的模块 ## 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` 可用。 - **jiangchang_desktop_sdk**:匠厂桌面应用自动化客户端(基于 CDP + Playwright 同步 API。**本包不声明 Playwright 依赖**;请在运行环境中自行安装 Playwright以便 `import playwright` 可用。
## 4. 快速开始 ## 4. 快速开始
@@ -56,7 +56,7 @@ finally:
### 桌面 E2E`jiangchang_desktop_sdk.e2e_helpers` ### 桌面 E2E`jiangchang_desktop_sdk.e2e_helpers`
Skill 的桌面端到端测试应通过宿主 IPC 获取**真实**当前用户数据目录,不要在 pytest 进程里手工设置 `JIANGCHANG_USER_ID` / `CLAW_USER_ID` 来伪造用户(否则会回退到 `_anon`)。 Skill 的桌面端到端测试应通过宿主 IPC 获取**真实**当前用户数据目录,不要在 pytest 进程里手工设置 `JIANGCHANG_USER_ID` 来伪造用户(否则会回退到 `_anon`)。
推荐 preflight 流程: 推荐 preflight 流程:
@@ -78,6 +78,71 @@ assert_skill_env_file(ctx, ["API_KEY"])
send_prompt_via_composer(page, "第一行\n第二行") 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 |
| `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 事件记录
- NDJSONUTF-8append-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` 关闭。
### 兄弟技能 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` 提供共享媒体资源解析能力。默认会从: `jiangchang_skill_core.media_assets` 提供共享媒体资源解析能力。默认会从:

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jiangchang-platform-kit" name = "jiangchang-platform-kit"
version = "1.0.14" version = "1.1.0"
description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK" description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"

View File

@@ -1,3 +1,17 @@
from .activity import (
emit,
finish,
get_job_id,
get_run_mode,
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 .client import EntitlementClient
from .config import ensure_env_file, get, get_bool, get_float, get_int from .config import ensure_env_file, get, get_bool, get_float, get_int
from .errors import EntitlementDeniedError, EntitlementError, EntitlementServiceError from .errors import EntitlementDeniedError, EntitlementError, EntitlementServiceError
@@ -5,8 +19,10 @@ from .guard import enforce_entitlement
from .models import EntitlementResult from .models import EntitlementResult
from .runtime_env import ( from .runtime_env import (
apply_cli_local_defaults, apply_cli_local_defaults,
ensure_runs_dir,
find_chrome_executable, find_chrome_executable,
get_data_root, get_data_root,
get_runs_dir,
get_sibling_skills_root, get_sibling_skills_root,
get_skills_root, get_skills_root,
get_user_id, get_user_id,
@@ -52,6 +68,18 @@ except ImportError:
rpa = None # type: ignore[assignment,misc] rpa = None # type: ignore[assignment,misc]
__all__ = [ __all__ = [
"call_sibling_json",
"call_sibling_json_array",
"emit",
"finish",
"get_job_id",
"get_run_mode",
"get_runs_dir",
"ensure_runs_dir",
"job_context",
"rpa_step",
"step",
"get_sibling_main_path",
"EntitlementClient", "EntitlementClient",
"EntitlementDeniedError", "EntitlementDeniedError",
"EntitlementError", "EntitlementError",
@@ -88,6 +116,7 @@ __all__ = [
"get_data_root", "get_data_root",
"get_float", "get_float",
"get_int", "get_int",
"get_runs_dir",
"get_sibling_skills_root", "get_sibling_skills_root",
"get_skills_root", "get_skills_root",
"get_skill_log_file_path", "get_skill_log_file_path",

View File

@@ -0,0 +1,343 @@
"""
Skill Activity Stream (SAS) v1 — Run Journal + stdout result contract.
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.
"""
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
_VALID_EMIT_TYPES = frozenset({"activity", "progress", "warn", "debug"})
_RESULT_MAX_CHARS = 4096
_cached_job_id: Optional[str] = None
_journal_path: Optional[str] = None
_meta_written: bool = False
_journal_file: Optional[Any] = None
F = TypeVar("F", bound=Callable[..., Any])
def _activity_debug_enabled() -> bool:
v = (os.getenv("JIANGCHANG_ACTIVITY_DEBUG") or "").strip().lower()
return v in ("1", "true", "yes", "on")
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_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 _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(
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."""
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 step(text: str, **kwargs: Any) -> None:
"""Shorthand for emit(type='activity')."""
emit(text, type="activity", **kwargs)
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 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: 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:
emit(f"{step_name}")
try:
result = await fn(*args, **kwargs)
emit(f"{step_name}")
return result
except Exception as exc:
emit(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:
emit(f"{step_name}")
try:
result = fn(*args, **kwargs)
emit(f"{step_name}")
return result
except Exception as exc:
emit(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 state (tests only)."""
global _cached_job_id, _journal_path, _meta_written, _journal_file
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

View File

@@ -743,7 +743,7 @@ def probe_background_music(
if not music_dir.is_dir(): if not music_dir.is_dir():
music_dir = Path(root) if Path(root).is_dir() else None music_dir = Path(root) if Path(root).is_dir() else None
else: 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) assets_root = resolve_media_assets_root(data_root or None, env_map)
status = _inspect_media_assets(assets_root, source="local") status = _inspect_media_assets(assets_root, source="local")
music_dir = status.music_dir music_dir = status.music_dir

View File

@@ -5,10 +5,11 @@ from __future__ import annotations
import os import os
from datetime import datetime from datetime import datetime
from .. import config
def _artifacts_enabled() -> bool: def _artifacts_enabled() -> bool:
v = (os.environ.get("OPENCLAW_ARTIFACTS_ON_FAILURE") or "1").strip().lower() return config.get_bool("OPENCLAW_ARTIFACTS_ON_FAILURE", default=True)
return v not in ("0", "false", "no", "off")
def artifact_path(data_dir: str, batch_id: str, tag: str, ext: str = "png") -> str: def artifact_path(data_dir: str, batch_id: str, tag: str, ext: str = "png") -> str:

View File

@@ -2,9 +2,9 @@
from __future__ import annotations from __future__ import annotations
import os
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from .. import config
from ..runtime_env import find_chrome_executable from ..runtime_env import find_chrome_executable
from .stealth import ( from .stealth import (
STEALTH_INIT_SCRIPT, STEALTH_INIT_SCRIPT,
@@ -31,8 +31,7 @@ async def launch_persistent_browser(
录屏由 RpaVideoSessionffmpeg负责本函数不向 Playwright 传递任何录屏参数。 录屏由 RpaVideoSessionffmpeg负责本函数不向 Playwright 传递任何录屏参数。
""" """
if headless is None: if headless is None:
v = (os.environ.get("OPENCLAW_BROWSER_HEADLESS") or "0").strip().lower() headless = config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False)
headless = v in ("1", "true", "yes", "on")
chrome = executable_path or find_chrome_executable() chrome = executable_path or find_chrome_executable()
if not chrome: if not chrome:

View File

@@ -5,7 +5,7 @@
from __future__ import annotations from __future__ import annotations
import os from .. import config
STEALTH_INIT_SCRIPT = """ STEALTH_INIT_SCRIPT = """
(() => { (() => {
@@ -48,8 +48,7 @@ STEALTH_INIT_SCRIPT = """
def stealth_enabled() -> bool: def stealth_enabled() -> bool:
v = (os.environ.get("OPENCLAW_PLAYWRIGHT_STEALTH") or "1").strip().lower() return config.get_bool("OPENCLAW_PLAYWRIGHT_STEALTH", default=True)
return v not in ("0", "false", "no", "off")
def persistent_context_launch_parts( def persistent_context_launch_parts(

View File

@@ -568,12 +568,14 @@ class RpaVideoSession:
batch_id: str, batch_id: str,
title: str = "", title: str = "",
closing_title: str = "", closing_title: str = "",
emit_activity: bool = True,
) -> None: ) -> None:
self.skill_slug = skill_slug self.skill_slug = skill_slug
self.skill_data_dir = os.path.abspath(skill_data_dir) self.skill_data_dir = os.path.abspath(skill_data_dir)
self.batch_id = batch_id self.batch_id = batch_id
self.title = title self.title = title
self.closing_title = closing_title self.closing_title = closing_title
self.emit_activity = emit_activity
self.enabled = _record_video_enabled() self.enabled = _record_video_enabled()
self.warnings: List[str] = [] self.warnings: List[str] = []
self.output_video_path: Optional[str] = None self.output_video_path: Optional[str] = None
@@ -631,13 +633,25 @@ class RpaVideoSession:
self.add_step(text, duration=duration) self.add_step(text, duration=duration)
def add_step(self, text: str, *, duration: float = 4.0) -> None: 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: if not self.enabled:
return return
if self._t0 is None: if self._t0 is None:
self._t0 = time.monotonic() self._t0 = time.monotonic()
elapsed = time.monotonic() - self._t0 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: def _close_ffmpeg_record_log(self) -> None:
fh = self._ffmpeg_record_log_file fh = self._ffmpeg_record_log_file

View File

@@ -11,6 +11,7 @@ from pathlib import Path
from typing import Any, Mapping from typing import Any, Mapping
import jiangchang_skill_core import jiangchang_skill_core
from . import config
from .media_assets import probe_background_music, probe_media_assets, resolve_media_assets_root from .media_assets import probe_background_music, probe_media_assets, resolve_media_assets_root
from .runtime_env import platform_default_data_root from .runtime_env import platform_default_data_root
@@ -36,7 +37,6 @@ class RuntimeDiagnostics:
platform_kit_version_ok: bool | None platform_kit_version_ok: bool | None
jiangchang_skill_core_file: str | None jiangchang_skill_core_file: str | None
claw_data_root: str | None
jiangchang_data_root: str | None jiangchang_data_root: str | None
resolved_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: 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: if root:
return root return root
return platform_default_data_root() return platform_default_data_root()
@@ -163,7 +163,6 @@ def collect_runtime_diagnostics(
env_map = _env_dict(env) env_map = _env_dict(env)
issues: list[RuntimeIssue] = [] issues: list[RuntimeIssue] = []
claw_data_root = env_map.get("CLAW_DATA_ROOT")
jiangchang_data_root = env_map.get("JIANGCHANG_DATA_ROOT") jiangchang_data_root = env_map.get("JIANGCHANG_DATA_ROOT")
resolved_data_root = _resolve_data_root(env_map) resolved_data_root = _resolve_data_root(env_map)
@@ -230,7 +229,10 @@ def collect_runtime_diagnostics(
) )
if record_video is None: 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: else:
record_video_enabled = record_video record_video_enabled = record_video
@@ -263,7 +265,6 @@ def collect_runtime_diagnostics(
platform_kit_min_version=platform_kit_min_version, platform_kit_min_version=platform_kit_min_version,
platform_kit_version_ok=platform_ok, platform_kit_version_ok=platform_ok,
jiangchang_skill_core_file=core_file, jiangchang_skill_core_file=core_file,
claw_data_root=claw_data_root,
jiangchang_data_root=jiangchang_data_root, jiangchang_data_root=jiangchang_data_root,
resolved_data_root=resolved_data_root, resolved_data_root=resolved_data_root,
media_assets_root=str(media_root_path), 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_min_version: {diagnostics.platform_kit_min_version or ''}",
f"platform_kit_version_ok: {diagnostics.platform_kit_version_ok}", f"platform_kit_version_ok: {diagnostics.platform_kit_version_ok}",
f"jiangchang_skill_core_file: {diagnostics.jiangchang_skill_core_file or ''}", 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"JIANGCHANG_DATA_ROOT: {diagnostics.jiangchang_data_root or ''}",
f"resolved_data_root: {diagnostics.resolved_data_root or ''}", f"resolved_data_root: {diagnostics.resolved_data_root or ''}",
f"media_assets_root: {diagnostics.media_assets_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_min_version": diagnostics.platform_kit_min_version,
"platform_kit_version_ok": diagnostics.platform_kit_version_ok, "platform_kit_version_ok": diagnostics.platform_kit_version_ok,
"jiangchang_skill_core_file": diagnostics.jiangchang_skill_core_file, "jiangchang_skill_core_file": diagnostics.jiangchang_skill_core_file,
"CLAW_DATA_ROOT": diagnostics.claw_data_root,
"JIANGCHANG_DATA_ROOT": diagnostics.jiangchang_data_root, "JIANGCHANG_DATA_ROOT": diagnostics.jiangchang_data_root,
"resolved_data_root": diagnostics.resolved_data_root, "resolved_data_root": diagnostics.resolved_data_root,
"media_assets_root": diagnostics.media_assets_root, "media_assets_root": diagnostics.media_assets_root,

View File

@@ -34,22 +34,28 @@ def platform_default_data_root() -> str:
def get_data_root() -> str: def get_data_root() -> str:
env = ( env = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip()
os.getenv("CLAW_DATA_ROOT")
or os.getenv("JIANGCHANG_DATA_ROOT")
or ""
).strip()
if env: if env:
return env return env
return platform_default_data_root() return platform_default_data_root()
def get_user_id() -> str: def get_user_id() -> str:
return ( return (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "_anon"
os.getenv("CLAW_USER_ID")
or os.getenv("JIANGCHANG_USER_ID")
or "" def get_runs_dir() -> str:
).strip() or "_anon" """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: def _looks_like_skills_root(path: str) -> bool:
@@ -76,15 +82,13 @@ def get_skills_root() -> str:
优先级: 优先级:
1) JIANGCHANG_SKILLS_ROOT 1) JIANGCHANG_SKILLS_ROOT
2) CLAW_SKILLS_ROOT 2) JIANGCHANG_APP_ROOT{APP}/skills 存在且像技能根则用之;否则若 APP 下已有技能子目录则 APP 即根
3) JIANGCHANG_APP_ROOT{APP}/skills 存在且像技能根则用之;否则若 APP 下已有技能子目录则 APP 即根 3) Windows默认 D:\\AI\\jiangchang 同上规则
4) Windows默认 D:\\AI\\jiangchang 同上规则 4) 其他平台:~/.openclaw/skills
5) 其他平台:~/.openclaw/skills
""" """
for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"): v = (os.getenv("JIANGCHANG_SKILLS_ROOT") or "").strip()
v = (os.getenv(key) or "").strip() if v:
if v: return os.path.normpath(v)
return os.path.normpath(v)
app = (os.getenv("JIANGCHANG_APP_ROOT") or "").strip() app = (os.getenv("JIANGCHANG_APP_ROOT") or "").strip()
if sys.platform == "win32" and not app: if sys.platform == "win32" and not app:
@@ -110,8 +114,8 @@ def get_sibling_skills_root(skill_scripts_dir: str | None = None) -> str:
""" """
编排子进程调用兄弟技能时用:先根据本技能 scripts 目录推断并列根;若目录下能识别出兄弟技能 编排子进程调用兄弟技能时用:先根据本技能 scripts 目录推断并列根;若目录下能识别出兄弟技能
则用之(开发仓 D:\\OpenClaw 与安装目录 ~/.openclaw/skills 均满足「技能根之上一级 = 并列根」), 则用之(开发仓 D:\\OpenClaw 与安装目录 ~/.openclaw/skills 均满足「技能根之上一级 = 并列根」),
避免全局 JIANGCHANG_SKILLS_ROOT / CLAW_SKILLS_ROOT 指向与当前检出不一致时错调兄弟进程。 避免全局 JIANGCHANG_SKILLS_ROOT 指向与当前检出不一致时错调兄弟进程。
推断失败时再读上述环境变量,最后回落 get_skills_root()。 推断失败时再读 JIANGCHANG_SKILLS_ROOT,最后回落 get_skills_root()。
""" """
if skill_scripts_dir: if skill_scripts_dir:
scripts = os.path.abspath(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): if _looks_like_skills_root(inferred):
return os.path.normpath(inferred) return os.path.normpath(inferred)
for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"): v = (os.getenv("JIANGCHANG_SKILLS_ROOT") or "").strip()
v = (os.getenv(key) or "").strip() if v:
if v: return os.path.normpath(v)
return os.path.normpath(v)
return get_skills_root() return get_skills_root()
@@ -139,21 +142,10 @@ def apply_cli_local_defaults() -> None:
enabled = v in ("1", "true", "yes", "on") enabled = v in ("1", "true", "yes", "on")
if not enabled: if not enabled:
return return
if not ( if not (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip():
os.getenv("CLAW_DATA_ROOT") os.environ["JIANGCHANG_DATA_ROOT"] = platform_default_data_root()
or os.getenv("JIANGCHANG_DATA_ROOT") if not (os.getenv("JIANGCHANG_USER_ID") or "").strip():
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():
os.environ["JIANGCHANG_USER_ID"] = DEFAULT_LOCAL_USER_ID.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": def find_chrome_executable() -> "str | None":
"""查找系统已安装的 Chrome 或 Edge 可执行文件路径。 """查找系统已安装的 Chrome 或 Edge 可执行文件路径。

View 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

View 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()

View 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()

View File

@@ -57,7 +57,7 @@ def test_collect_runtime_diagnostics_json_serializable(
) -> None: ) -> None:
data_root = tmp_path / "data" data_root = tmp_path / "data"
env = { env = {
"CLAW_DATA_ROOT": str(data_root), "JIANGCHANG_DATA_ROOT": str(data_root),
"OPENCLAW_RECORD_VIDEO": "0", "OPENCLAW_RECORD_VIDEO": "0",
} }
media_root = data_root / "shared" / "media-assets" media_root = data_root / "shared" / "media-assets"
@@ -110,7 +110,7 @@ def test_platform_kit_min_version_none_skips_version_issue(
tmp_path: Path, tmp_path: Path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
env = {"CLAW_DATA_ROOT": str(tmp_path / "data")} env = {"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")}
monkeypatch.setattr( monkeypatch.setattr(
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets", "jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
lambda *a, **k: ma.MediaAssetsStatus( lambda *a, **k: ma.MediaAssetsStatus(
@@ -144,7 +144,7 @@ def test_platform_kit_missing_records_issue(
tmp_path: Path, tmp_path: Path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
env = {"CLAW_DATA_ROOT": str(tmp_path / "data")} env = {"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")}
monkeypatch.setattr( monkeypatch.setattr(
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets", "jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
lambda *a, **k: ma.MediaAssetsStatus( lambda *a, **k: ma.MediaAssetsStatus(
@@ -182,7 +182,7 @@ def test_record_video_ffmpeg_and_music_warnings_not_fatal(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
media_root = tmp_path / "shared" / "media-assets" 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( monkeypatch.setattr(
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets", "jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
lambda *a, **k: ma.MediaAssetsStatus( 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.parent.mkdir(parents=True)
fake_core.write_text("# vendored\n", encoding="utf-8") 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)): with patch.object(jiangchang_skill_core, "__file__", str(fake_core)):
diag = collect_runtime_diagnostics( diag = collect_runtime_diagnostics(
skill_slug="my-skill", 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() 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( def test_format_runtime_health_lines_core_fields(
tmp_path: Path, tmp_path: Path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
env = {"CLAW_DATA_ROOT": str(tmp_path / "data")} env = {"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")}
monkeypatch.setattr( monkeypatch.setattr(
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets", "jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
lambda *a, **k: ma.MediaAssetsStatus( lambda *a, **k: ma.MediaAssetsStatus(
@@ -301,7 +346,6 @@ def test_runtime_diagnostics_frozen_dataclass() -> None:
platform_kit_min_version=None, platform_kit_min_version=None,
platform_kit_version_ok=None, platform_kit_version_ok=None,
jiangchang_skill_core_file=None, jiangchang_skill_core_file=None,
claw_data_root=None,
jiangchang_data_root=None, jiangchang_data_root=None,
resolved_data_root="/tmp", resolved_data_root="/tmp",
media_assets_root="/tmp/media", 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_min_version=None,
platform_kit_version_ok=None, platform_kit_version_ok=None,
jiangchang_skill_core_file=None, jiangchang_skill_core_file=None,
claw_data_root=None,
jiangchang_data_root=None, jiangchang_data_root=None,
resolved_data_root="/tmp", resolved_data_root="/tmp",
media_assets_root="/tmp/media", media_assets_root="/tmp/media",

204
tests/test_runtime_env.py Normal file
View File

@@ -0,0 +1,204 @@
# -*- coding: utf-8 -*-
"""runtime_envJIANGCHANG_* 路径契约与兄弟技能根解析。"""
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()

View 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()

View 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
View File

@@ -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" }, { 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]] [[package]]
name = "idna" name = "idna"
version = "3.13" version = "3.13"
@@ -127,14 +213,60 @@ wheels = [
[[package]] [[package]]
name = "jiangchang-platform-kit" name = "jiangchang-platform-kit"
version = "0.1.0" version = "1.0.15"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "mss" },
{ name = "playwright" },
{ name = "requests" }, { name = "requests" },
] ]
[package.metadata] [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]] [[package]]
name = "requests" 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" }, { 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]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.6.3" version = "2.6.3"