tytcli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,200 @@
1
+ """``tytcli sentiment`` —— 机构交易情绪指数日报(sentimentIndex 模块)。
2
+
3
+ ``/middleApi/sentimentIndex`` 根下的两个只读命令:
4
+
5
+ * ``dates`` —— 哪些天有日报
6
+ * ``image`` —— 把某一天的日报下载成 PNG
7
+
8
+ ``image`` 是本 CLI 里第一个 payload 为**二进制**的命令:该端点返回的是
9
+ ``image/png``(约 4.3 MiB)而不是 JSON 信封,所以字节落盘,stdout 只报告文件的
10
+ 路径与摘要——这样"恰好一个 JSON 文档"的契约得以保住,而不必把图片本身塞进去。
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import re
17
+ from datetime import datetime
18
+ from pathlib import Path
19
+ from typing import Any, cast
20
+
21
+ import click
22
+
23
+ from ..client import (
24
+ IMAGE_MAGICS,
25
+ SENTIMENT_DATE_FORMAT,
26
+ TytClient,
27
+ )
28
+ from ..errors import ApiError, UsageError
29
+ from ..output import emit_ok
30
+ from .shared import raise_for_envelope, require_config
31
+
32
+ #: ``reportDate`` 必须是 ``yyyy-MM-dd``。在客户端就校验,因为服务端对不可用的
33
+ #: 日期回答的是 ``-101 服务器繁忙``——这句报文会把调用方引向"服务端出故障了",
34
+ #: 而真正的原因只是打错了一个日期。
35
+ DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
36
+
37
+ #: 下载日报的默认文件名。这个命名空间归本工具所有,所以覆盖风险只限于它自己
38
+ #: 写出来的文件。
39
+ DEFAULT_IMAGE_NAME = "tytcli-sentiment-{date}{ext}"
40
+
41
+
42
+ def _validate_date(raw: str) -> str:
43
+ """``raw`` 是真实的 ``yyyy-MM-dd`` 日期就原样返回,否则抛用法错误。"""
44
+ if not DATE_PATTERN.match(raw):
45
+ raise UsageError(
46
+ f"--date 必须是 yyyy-MM-dd 格式(当前 {raw!r})。",
47
+ code="USAGE_DATE_INVALID",
48
+ details={"date": raw, "expected_format": "yyyy-MM-dd"},
49
+ )
50
+ try:
51
+ datetime.strptime(raw, SENTIMENT_DATE_FORMAT)
52
+ except ValueError as exc:
53
+ raise UsageError(
54
+ f"--date 不是真实存在的日期(当前 {raw!r})。",
55
+ code="USAGE_DATE_INVALID",
56
+ details={"date": raw, "reason": str(exc)},
57
+ ) from exc
58
+ return raw
59
+
60
+
61
+ def _detect_image_format(content: bytes) -> tuple[str, str] | None:
62
+ """把魔数映射成 ``(格式, 扩展名)``;不是图片则返回 ``None``。"""
63
+ for magic, name, ext in IMAGE_MAGICS:
64
+ if content.startswith(magic):
65
+ return name, ext
66
+ return None
67
+
68
+
69
+ @click.group()
70
+ def sentiment() -> None:
71
+ """机构交易情绪指数日报。
72
+
73
+ \b
74
+ 注意:与公众号模块一样,该模块可能未在所有环境部署。
75
+ 若命令返回 HTTP 404,错误的 details.hint 会说明原因。
76
+ """
77
+
78
+
79
+ @sentiment.command("dates")
80
+ @click.pass_context
81
+ def sentiment_dates(ctx: click.Context) -> None:
82
+ """列出有日报的日期。
83
+
84
+ \b
85
+ 输出 ``data`` 是日期字符串列表(yyyy-MM-dd,新→旧)。
86
+ 拿到日期后取图:
87
+ tytcli sentiment image --date <YYYY-MM-DD>
88
+ """
89
+ pretty: bool = ctx.obj["pretty"]
90
+
91
+ config = require_config()
92
+ client = TytClient(
93
+ config.base_url,
94
+ config.require_token(),
95
+ middle_base_url=config.middle_base_url,
96
+ timeout=config.timeout_seconds,
97
+ )
98
+ envelope = client.sentiment_daily_report_dates()
99
+ raise_for_envelope(
100
+ envelope, env_name=config.active_env, base_url=config.middle_base_url
101
+ )
102
+
103
+ dates_raw = envelope.payload.get("Data")
104
+ # isinstance() 会把 Any 窄化成 list[Unknown];紧跟一次 cast,len() 才有类型。
105
+ dates: list[Any] | None = (
106
+ cast("list[Any]", dates_raw) if isinstance(dates_raw, list) else None
107
+ )
108
+ emit_ok(
109
+ dates_raw,
110
+ pretty=pretty,
111
+ env=config.active_env,
112
+ base_url=config.middle_base_url,
113
+ api_root="middleApi",
114
+ http_status=envelope.http_status,
115
+ count=len(dates) if dates is not None else None,
116
+ )
117
+
118
+
119
+ @sentiment.command("image")
120
+ @click.option(
121
+ "--date",
122
+ "report_date",
123
+ required=True,
124
+ metavar="YYYY-MM-DD",
125
+ help="日报日期。可用 `tytcli sentiment dates` 查看有哪些天。",
126
+ )
127
+ @click.option(
128
+ "--output",
129
+ "output_option",
130
+ default=None,
131
+ type=click.Path(dir_okay=False, path_type=Path),
132
+ metavar="PATH",
133
+ help=f"图片保存路径。默认当前目录下 {DEFAULT_IMAGE_NAME.format(date='<日期>', ext='.<格式>')}。",
134
+ )
135
+ @click.pass_context
136
+ def sentiment_image(
137
+ ctx: click.Context, report_date: str, output_option: Path | None
138
+ ) -> None:
139
+ """下载某一天的日报图片。
140
+
141
+ \b
142
+ 图片写入文件,stdout 只输出元数据:path / bytes / format / sha256。
143
+ 图片约 4 MiB,首次下载需要几秒。
144
+ """
145
+ pretty: bool = ctx.obj["pretty"]
146
+ report_date = _validate_date(report_date)
147
+
148
+ config = require_config()
149
+ client = TytClient(
150
+ config.base_url,
151
+ config.require_token(),
152
+ middle_base_url=config.middle_base_url,
153
+ timeout=config.timeout_seconds,
154
+ )
155
+ binary = client.sentiment_daily_report_image(report_date)
156
+
157
+ detected = _detect_image_format(binary.content)
158
+ if detected is None:
159
+ # content-type 说是图片,但字节并不是我们认得的任何一种。
160
+ # 把它存成 .png 扩展名,等于产出一个会撒谎的文件。
161
+ raise ApiError(
162
+ "服务端返回的内容不是可识别的图片格式。",
163
+ code="UNEXPECTED_SHAPE",
164
+ details={
165
+ "url": binary.url,
166
+ "http_status": binary.http_status,
167
+ "content_type": binary.content_type,
168
+ "bytes": len(binary.content),
169
+ "leading_bytes": binary.content[:16].hex(),
170
+ },
171
+ )
172
+ image_format, extension = detected
173
+
174
+ if output_option is not None:
175
+ path = output_option.expanduser()
176
+ else:
177
+ path = Path.cwd() / DEFAULT_IMAGE_NAME.format(date=report_date, ext=extension)
178
+ path.parent.mkdir(parents=True, exist_ok=True)
179
+ overwritten = path.exists()
180
+ path.write_bytes(binary.content)
181
+
182
+ emit_ok(
183
+ {
184
+ "report_date": report_date,
185
+ "path": str(path),
186
+ "bytes": len(binary.content),
187
+ "format": image_format,
188
+ "sha256": hashlib.sha256(binary.content).hexdigest(),
189
+ "content_type": binary.content_type,
190
+ "overwritten": overwritten,
191
+ },
192
+ pretty=pretty,
193
+ env=config.active_env,
194
+ base_url=config.middle_base_url,
195
+ api_root="middleApi",
196
+ http_status=binary.http_status,
197
+ )
198
+
199
+
200
+ __all__: list[str] = ["sentiment"]
@@ -0,0 +1,210 @@
1
+ """每个命令模块都需要的那些辅助函数。
2
+
3
+ ``require_config`` / ``raise_for_envelope`` / ``effective_config_payload`` 曾经是
4
+ ``cli.py`` 里的私有函数。现在它们跨模块被 import,所以改成公开的——再加个前导
5
+ 下划线只会误导人。
6
+
7
+ 列表裁剪那一半是后来加进来的:``commands/roadshow.py`` 需要同一套机制,于是从
8
+ ``commands/wechat.py`` 里提取出来。现在有三个列表端点共用它,而**共用正是目的**:
9
+ 裁剪契约(``trimmed`` / ``record_fields`` / ``unknown_fields`` /
10
+ ``available_fields`` / ``__tytcli_hint__``)是已发布输出形状的一部分,所以第二份
11
+ 实现就意味着第二件要保持诚实的东西。
12
+
13
+ 本模块不能变成命令模块:``tests/test_architecture.py`` 要求 ``commands/`` 下除
14
+ ``__init__.py`` 与 ``shared.py`` 之外的每个模块都必须贡献一个已注册命令。
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Callable, Sequence
20
+ from dataclasses import dataclass
21
+ from typing import Any, cast
22
+
23
+ from ..client import ApiEnvelope, TytClient, error_for_envelope
24
+ from ..config import ResolvedConfig, load_config, visible_envs
25
+ from ..errors import UsageError
26
+
27
+
28
+ def require_config(allow_missing_token: bool = False) -> ResolvedConfig:
29
+ """加载配置,并把配置类错误以 TytCliError 的形式抛出。"""
30
+ cfg = load_config()
31
+ if not allow_missing_token and cfg.token is None:
32
+ cfg.require_token()
33
+ return cfg
34
+
35
+
36
+ def effective_config_payload(cfg: ResolvedConfig) -> dict[str, Any]:
37
+ """"当前实际生效的配置"摘要,Token 已脱敏。
38
+
39
+ 一个构造器,两个入口:公开的 ``config show`` 与面向开发者(隐藏)的
40
+ ``env show``。共用才是重点——两者绝不能漂移,而且脱敏逻辑的 bug 修一处
41
+ 就等于修了两处。
42
+
43
+ ``known_envs`` 走 ``visible_envs()``,所以那个开发环境只会被说给**已经
44
+ 自己选中了它**的人听。
45
+ """
46
+ return {
47
+ "active_env": cfg.active_env,
48
+ "env_source": cfg.env_source,
49
+ "base_url": cfg.base_url,
50
+ "base_url_source": "code", # 固定在 config.BASE_URLS 里,不可覆盖
51
+ "token_configured": cfg.token is not None,
52
+ "token_source": cfg.token_source,
53
+ "token_hint": cfg.masked_token(),
54
+ "timeout_seconds": cfg.timeout_seconds,
55
+ "config_path": str(cfg.path),
56
+ "config_path_source": cfg.path_source,
57
+ "config_exists": cfg.path_exists,
58
+ "known_envs": list(visible_envs(cfg.active_env)),
59
+ }
60
+
61
+
62
+ def raise_for_envelope(envelope: ApiEnvelope, *, env_name: str, base_url: str) -> None:
63
+ """当解析出来的信封不是成功时抛错。
64
+
65
+ 这是 :func:`tytcli.client.error_for_envelope` 的一层薄包装:信封到错误的映射
66
+ 属于协议知识,所以住在协议层;而这一层让命令模块里的调用点保持一行、显而易见。
67
+ """
68
+ error = error_for_envelope(envelope, env_name=env_name, base_url=base_url)
69
+ if error is not None:
70
+ raise error
71
+
72
+
73
+ # --------------------------------------------------------------------------- #
74
+ # middle API 客户端
75
+ # --------------------------------------------------------------------------- #
76
+
77
+
78
+ def middle_client(config: ResolvedConfig) -> TytClient:
79
+ """一个能同时访问两个 API 根(核心根与 ``/middleApi``)的客户端。"""
80
+ return TytClient(
81
+ config.base_url,
82
+ config.require_token(),
83
+ middle_base_url=config.middle_base_url,
84
+ timeout=config.timeout_seconds,
85
+ )
86
+
87
+
88
+ # --------------------------------------------------------------------------- #
89
+ # 列表裁剪
90
+ # --------------------------------------------------------------------------- #
91
+
92
+
93
+ def unwrap_data(payload: dict[str, Any]) -> Any:
94
+ """返回信封的 ``Data`` 成员。
95
+
96
+ 与 ``whoami`` 不同(它的 ``data`` 是整个原样信封),基于本辅助函数构建的命令
97
+ 会拆开 ``Data``:信封的 ``Success`` / ``ErrorCode`` / ``Error`` 已经由 ``ok``
98
+ 和错误文档表达过了,而对 agent 来说,``data`` 在 ``--fields`` / ``--full`` 下
99
+ 保持**稳定形状**比逐字节重放信封更有用。``--full`` 仍然返回服务端发来的每一个
100
+ *记录*字段。
101
+ """
102
+ return payload.get("Data")
103
+
104
+
105
+ def observed_fields(records: Sequence[Any]) -> list[str]:
106
+ """所有记录键的并集,按首次出现的顺序。
107
+
108
+ 从响应里动态取,而不是写死,这样后端加字段时提示仍然是真的——它已经加过两次了。
109
+ 而且字段存在性在**同一次响应的不同行之间**也会变:2026-11-07 探测路演列表时,
110
+ 有些行没有 ``duration`` / ``meetingPwd`` / ``summary``,另一些行有。所以写死的
111
+ 字段表会逐行出错。
112
+ """
113
+ seen: list[str] = []
114
+ for record in records:
115
+ if isinstance(record, dict):
116
+ # isinstance() 会把 Any 窄化成 dict[Unknown, Unknown];紧跟一次 cast,
117
+ # 让键的类型确定为 str(这正是 AGENTS.md 为这些 API 返回的任意
118
+ # camelCase JSON 规定的写法)。
119
+ mapping = cast("dict[str, Any]", record)
120
+ for key in mapping:
121
+ if key not in seen:
122
+ seen.append(key)
123
+ return seen
124
+
125
+
126
+ def select_fields(record: dict[str, Any], keys: Sequence[str]) -> dict[str, Any]:
127
+ """把一条记录投影到 ``keys`` 上,本记录没有的键直接跳过。
128
+
129
+ 跳过而不是补成 ``null``:服务端本来就省略空值,凭空造出来只会增加字节数,
130
+ 还会暗示"这个字段存在,只是值为 null"。
131
+ """
132
+ return {key: record[key] for key in keys if key in record}
133
+
134
+
135
+ def parse_fields_option(raw: str) -> list[str]:
136
+ """拆分 ``--fields a,b,c``,保留调用方给的顺序并去重。"""
137
+ keys = [part.strip() for part in raw.split(",")]
138
+ keys = [key for key in keys if key]
139
+ if not keys:
140
+ raise UsageError(
141
+ "--fields 不能为空。请给出逗号分隔的字段名,如 --fields title,appId。",
142
+ code="USAGE_FIELDS_EMPTY",
143
+ )
144
+ unique: list[str] = []
145
+ for key in keys:
146
+ if key not in unique:
147
+ unique.append(key)
148
+ return unique
149
+
150
+
151
+ @dataclass(frozen=True)
152
+ class TrimmedPage:
153
+ """一页数据(裁剪或未裁剪),外加那些让裁剪"不静默"的 meta 键。"""
154
+
155
+ #: 放进 ``data`` 的内容
156
+ payload: Any
157
+ #: ``trimmed`` 及其同伴;由调用方合并进自己的 meta
158
+ meta: dict[str, Any]
159
+ #: ``__tytcli_hint__`` 的值;调用方用了 --fields 时为 None
160
+ hint: str | None
161
+
162
+
163
+ def trim_page(
164
+ *,
165
+ inner_raw: Any,
166
+ list_key: str,
167
+ default_fields: Sequence[str],
168
+ selected: list[str] | None,
169
+ full: bool,
170
+ make_hint: Callable[[Sequence[str], list[str]], str] | None = None,
171
+ ) -> TrimmedPage:
172
+ """把本项目的列表裁剪契约应用到一个分页响应上。
173
+
174
+ **绝不静默丢字段**:结果里带着 ``trimmed``、``record_fields``,以及(当请求了
175
+ 本次响应里没有的字段时)``unknown_fields`` + ``available_fields``。``--full``、
176
+ 空页、以及形状不符合预期的情况都原样透传 ``Data``——空页没有字段可裁,往它上面
177
+ 加键只是噪音。
178
+ """
179
+ inner_map: dict[str, Any] | None = (
180
+ cast("dict[str, Any]", inner_raw) if isinstance(inner_raw, dict) else None
181
+ )
182
+ records_raw = inner_map.get(list_key) if inner_map is not None else None
183
+ records: list[Any] = (
184
+ cast("list[Any]", records_raw) if isinstance(records_raw, list) else []
185
+ )
186
+ observed = observed_fields(records)
187
+
188
+ if full or not records or inner_map is None:
189
+ meta: dict[str, Any] = {"trimmed": False}
190
+ if full and observed:
191
+ meta["record_fields"] = observed
192
+ return TrimmedPage(payload=inner_raw, meta=meta, hint=None)
193
+
194
+ keys: list[str] = list(selected) if selected is not None else list(default_fields)
195
+ trimmed = [
196
+ select_fields(cast("dict[str, Any]", record), keys)
197
+ if isinstance(record, dict)
198
+ else record
199
+ for record in records
200
+ ]
201
+ meta = {"trimmed": True, "record_fields": keys}
202
+ if observed:
203
+ unknown = [key for key in keys if key not in observed]
204
+ if unknown:
205
+ # 请求了本次响应没有携带的字段。明说出来,好过让 agent 自己纳闷
206
+ # 为什么每条记录里都没有这个键。
207
+ meta["unknown_fields"] = unknown
208
+ meta["available_fields"] = observed
209
+ hint = make_hint(default_fields, observed) if (make_hint and selected is None) else None
210
+ return TrimmedPage(payload={**inner_map, list_key: trimmed}, meta=meta, hint=hint)
@@ -0,0 +1,95 @@
1
+ """``tytcli upgrade`` —— 显式地检查并升级(查的是解析出来的那个包索引)。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import click
8
+
9
+ # 以模块方式 import(而不是 `from ..selfupdate import plan_upgrade`),这样测试
10
+ # monkeypatch `selfupdate.plan_upgrade` 才会生效。把名字绑定到本地命名空间会
11
+ # **静默**让 patch 失效,于是测试真的去跑一次安装器——这个错误曾经让套件多花 29 秒
12
+ # 才被发现。
13
+ from .. import __version__, selfupdate
14
+ from ..errors import ApiError
15
+ from ..output import emit_ok
16
+
17
+
18
+ @click.command()
19
+ @click.option("--check", "check_only", is_flag=True, help="只检查是否有新版本,不执行升级。")
20
+ @click.option("--force", is_flag=True, help="忽略版本比较,强制执行升级命令。")
21
+ @click.pass_context
22
+ def upgrade(ctx: click.Context, check_only: bool, force: bool) -> None:
23
+ """检查包索引中的新版本,有则升级。
24
+
25
+ \b
26
+ 隐式检查:每条命令启动时会自动做一次(10 分钟最多一次),
27
+ 发现新版先升级再重新执行原命令。**没有关闭开关**:产品契约是
28
+ 始终运行最新版。本命令是显式版本,会绕过缓存直接联网,并把失败
29
+ 如实报出来(“无法判断”不等于“无需升级”)。
30
+ """
31
+ pretty: bool = ctx.obj["pretty"]
32
+
33
+ # 与隐式钩子不同,这里的失败必须暴露出来:本命令存在的意义正是告诉用户
34
+ # 能不能升级。
35
+ latest = selfupdate.fetch_latest_version()
36
+ result = selfupdate.record_check(latest)
37
+ plan = selfupdate.plan_upgrade(index=result.index_url)
38
+ newer = result.is_newer_than(__version__)
39
+
40
+ info: dict[str, Any] = {
41
+ "current_version": __version__,
42
+ "latest_version": latest or None,
43
+ "update_available": newer,
44
+ "index_url": result.index_url,
45
+ # 是哪个旋钮选出了这个 URL(某个环境变量名,或 "default")。没有它,
46
+ # "它为什么在查 pypi.org?"从输出里就无从回答。
47
+ "index_source": selfupdate.index_source(),
48
+ "install_kind": plan.kind,
49
+ "install_reason": plan.reason,
50
+ "upgrade_argv": plan.argv or None,
51
+ }
52
+
53
+ if not latest:
54
+ # 这**不是**成功:命令被问"需不需要升级",而它答不出来。在这里报
55
+ # ok:true 会让任何按退出码分支的自动化拿到假阳性——而且它会与"索引不可达"
56
+ # 那种情形自相矛盾,后者已经是失败了。
57
+ raise ApiError(
58
+ "包索引上没有该包的任何版本,无法判断是否需要升级。",
59
+ code="UPDATE_NOT_PUBLISHED",
60
+ details={
61
+ **info,
62
+ "hint": (
63
+ f"确认包已发布到 {result.index_url}。源码安装"
64
+ "(uv sync / pip install -e .)本来就不在索引上,属正常情况。"
65
+ ),
66
+ },
67
+ )
68
+
69
+ if check_only:
70
+ emit_ok(info, pretty=pretty)
71
+ return
72
+
73
+ if not newer and not force:
74
+ info["upgraded"] = False
75
+ info["reason"] = "已是最新版本"
76
+ emit_ok(info, pretty=pretty)
77
+ return
78
+
79
+ if not plan.can_upgrade:
80
+ raise ApiError(
81
+ f"无法自动升级:{plan.reason}",
82
+ code="UPDATE_UNSUPPORTED",
83
+ details={**info, "hint": selfupdate.manual_instruction()},
84
+ )
85
+
86
+ output = selfupdate.run_upgrade(plan)
87
+ info["upgraded"] = True
88
+ # 本进程跑的仍是**旧**代码;新版要到下次调用才生效。自动化需要把这件事
89
+ # 当数据拿到,而不是当成 stderr 上的一句旁白。
90
+ info["restart_required"] = True
91
+ info["note"] = (
92
+ f"升级命令已执行;新版将在下次调用 tytcli 时生效(当前进程仍为 {__version__})。"
93
+ )
94
+ info["output"] = output[-2000:]
95
+ emit_ok(info, pretty=pretty)