weather-observation-cli 0.1.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.
- weather_observation_cli/__init__.py +3 -0
- weather_observation_cli/client.py +87 -0
- weather_observation_cli/errors.py +11 -0
- weather_observation_cli/main.py +470 -0
- weather_observation_cli/storage.py +171 -0
- weather_observation_cli-0.1.0.dist-info/METADATA +80 -0
- weather_observation_cli-0.1.0.dist-info/RECORD +9 -0
- weather_observation_cli-0.1.0.dist-info/WHEEL +4 -0
- weather_observation_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""限定目标服务且不跟随重定向的 HTTP 客户端。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .errors import CliError
|
|
11
|
+
|
|
12
|
+
DEFAULT_SERVER = "https://weather.cavonxx.com"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def normalize_server(value: str) -> str:
|
|
16
|
+
"""只允许 HTTPS 服务源,开发时允许本机 HTTP。"""
|
|
17
|
+
url = urlsplit(value)
|
|
18
|
+
if url.username or url.password or url.query or url.fragment or url.path not in ("", "/"):
|
|
19
|
+
raise CliError("INVALID_ARGUMENT", "服务地址必须是没有路径、凭据或查询参数的服务源", 2)
|
|
20
|
+
if not url.hostname or (
|
|
21
|
+
url.scheme != "https"
|
|
22
|
+
and not (url.scheme == "http" and url.hostname in ("127.0.0.1", "localhost", "::1"))
|
|
23
|
+
):
|
|
24
|
+
raise CliError("INVALID_ARGUMENT", "服务地址必须使用 HTTPS,本机开发可使用 HTTP", 2)
|
|
25
|
+
try:
|
|
26
|
+
port = url.port
|
|
27
|
+
except ValueError as error:
|
|
28
|
+
raise CliError("INVALID_ARGUMENT", "服务端口无效", 2) from error
|
|
29
|
+
host = f"[{url.hostname}]" if ":" in url.hostname else url.hostname.lower()
|
|
30
|
+
suffix = f":{port}" if port and port != (443 if url.scheme == "https" else 80) else ""
|
|
31
|
+
return f"{url.scheme}://{host}{suffix}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Client:
|
|
35
|
+
"""不自动重试写请求,也不泄露服务响应中的敏感内容。"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, server: str, token: str | None = None) -> None:
|
|
38
|
+
"""保存目标服务和当前独立凭据。"""
|
|
39
|
+
self.server = server
|
|
40
|
+
self.token = token
|
|
41
|
+
|
|
42
|
+
def request(
|
|
43
|
+
self,
|
|
44
|
+
method: str,
|
|
45
|
+
path: str,
|
|
46
|
+
*,
|
|
47
|
+
params: dict[str, Any] | None = None,
|
|
48
|
+
body: dict[str, Any] | None = None,
|
|
49
|
+
binary: bool = False,
|
|
50
|
+
) -> Any:
|
|
51
|
+
"""返回 JSON 或 ZIP,HTTP 与网络失败使用稳定错误码。"""
|
|
52
|
+
headers = {"User-Agent": "weather-observation-cli/0.1.0"}
|
|
53
|
+
if self.token:
|
|
54
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
55
|
+
try:
|
|
56
|
+
with httpx.Client(timeout=120, follow_redirects=False) as client:
|
|
57
|
+
response = client.request(
|
|
58
|
+
method, self.server + path, params=params, json=body, headers=headers
|
|
59
|
+
)
|
|
60
|
+
except httpx.HTTPError as error:
|
|
61
|
+
raise CliError(
|
|
62
|
+
"NETWORK_ERROR", "服务请求未完成;写操作的结果可能不确定,请先查询后再决定是否重试"
|
|
63
|
+
) from error
|
|
64
|
+
codes = {
|
|
65
|
+
401: ("AUTH_REQUIRED", "登录无效或已过期,请运行 auth login"),
|
|
66
|
+
403: ("PERMISSION_DENIED", "没有执行此操作的权限"),
|
|
67
|
+
404: ("NOT_FOUND", "指定对象或服务接口不存在"),
|
|
68
|
+
409: ("CONFLICT", "操作与当前状态冲突,请重新查询"),
|
|
69
|
+
410: ("AUTH_REQUEST_EXPIRED", "授权请求已过期,请重新登录"),
|
|
70
|
+
422: ("INVALID_ARGUMENT", "参数无效,请查看对应命令 --help"),
|
|
71
|
+
429: ("RATE_LIMITED", "请求过于频繁,请稍后重试"),
|
|
72
|
+
}
|
|
73
|
+
if not response.is_success:
|
|
74
|
+
code, message = codes.get(
|
|
75
|
+
response.status_code, ("SERVICE_ERROR", f"服务返回 HTTP {response.status_code}")
|
|
76
|
+
)
|
|
77
|
+
raise CliError(code, message)
|
|
78
|
+
if response.status_code == 204:
|
|
79
|
+
return None
|
|
80
|
+
if binary:
|
|
81
|
+
if not response.content.startswith(b"PK"):
|
|
82
|
+
raise CliError("INVALID_RESPONSE", "服务没有返回有效 ZIP")
|
|
83
|
+
return response.content
|
|
84
|
+
try:
|
|
85
|
+
return response.json()
|
|
86
|
+
except ValueError as error:
|
|
87
|
+
raise CliError("INVALID_RESPONSE", "服务没有返回有效 JSON") from error
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""AI 可调用的气象观测命令入口。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
import io
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import platform
|
|
11
|
+
import secrets
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
from datetime import UTC, datetime, timedelta
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, NoReturn, Protocol, cast
|
|
17
|
+
from urllib.parse import quote
|
|
18
|
+
from uuid import uuid4
|
|
19
|
+
|
|
20
|
+
from . import __version__
|
|
21
|
+
from .client import DEFAULT_SERVER, Client, normalize_server
|
|
22
|
+
from .errors import CliError
|
|
23
|
+
from .storage import Credentials
|
|
24
|
+
|
|
25
|
+
FIELDS = ("temperature", "humidity", "wind_speed", "hourly_rain", "pressure")
|
|
26
|
+
DATA_HELP = """时间为带 +08:00 的 ISO 8601 整点,包含首尾,结束减开始最多 366 天。
|
|
27
|
+
AI 将自然语言时间转换成明确范围。每小时保留一条记录,null 为缺测,0 为真实零值;
|
|
28
|
+
不插值、不补零、不默认返回缺测统计。record_count 是小时记录数,不代表有效值数。
|
|
29
|
+
字段及单位:temperature 空气温度 ℃;humidity 相对湿度 %;wind_speed 风速 m/s;
|
|
30
|
+
hourly_rain 小时雨量 mm;pressure 气压 hPa。未选择的要素不返回。
|
|
31
|
+
默认完整写入执行目录 weather-output/,返回绝对路径与记录数;--stdout 完整输出。
|
|
32
|
+
数据文件包含 schema_version、source(id/name)、start_time、end_time、points。
|
|
33
|
+
points 每条包含 observed_at 和所选字段。后续采集可修订历史值。
|
|
34
|
+
示例:weather-observation observations query --station <ID>
|
|
35
|
+
--start 2026-09-11T00:00:00+08:00 --end 2026-09-11T23:00:00+08:00 --fields hourly_rain
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Api(Protocol):
|
|
40
|
+
"""便于独立验证的 HTTP 边界。"""
|
|
41
|
+
|
|
42
|
+
def request(
|
|
43
|
+
self,
|
|
44
|
+
method: str,
|
|
45
|
+
path: str,
|
|
46
|
+
*,
|
|
47
|
+
params: dict[str, Any] | None = None,
|
|
48
|
+
body: dict[str, Any] | None = None,
|
|
49
|
+
binary: bool = False,
|
|
50
|
+
) -> Any:
|
|
51
|
+
"""执行一条业务请求。"""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Parser(argparse.ArgumentParser):
|
|
56
|
+
"""把参数错误交给统一 JSON 出口。"""
|
|
57
|
+
|
|
58
|
+
def error(self, message: str) -> NoReturn:
|
|
59
|
+
"""不回显可能含秘密的未知参数值。"""
|
|
60
|
+
raise CliError("INVALID_ARGUMENT", "参数无效或缺少必填项,请查看对应命令 --help", 2)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parser() -> Parser:
|
|
64
|
+
"""构造无需登录即可读取的完整分层帮助。"""
|
|
65
|
+
root = Parser(
|
|
66
|
+
prog="weather-observation",
|
|
67
|
+
description="通过现有账号查询深圳气象观测、下载 Excel 报表及管理本人收藏。",
|
|
68
|
+
epilog=(
|
|
69
|
+
"首次运行 auth login,浏览器批准后再次运行。业务输出为 JSON;"
|
|
70
|
+
"错误为 ok=false/error(code,message)。退出码 0 成功、2 参数错误、"
|
|
71
|
+
"1 其他失败。仅支持普通用户业务。"
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
root.add_argument("--version", action="version", version=__version__)
|
|
75
|
+
root.add_argument(
|
|
76
|
+
"--server",
|
|
77
|
+
default=DEFAULT_SERVER,
|
|
78
|
+
help="开发测试用服务源;凭据与默认服务隔离,置于子命令之前",
|
|
79
|
+
)
|
|
80
|
+
groups = root.add_subparsers(dest="group", required=True)
|
|
81
|
+
auth = groups.add_parser("auth", help="登录与授权管理").add_subparsers(
|
|
82
|
+
dest="action", required=True
|
|
83
|
+
)
|
|
84
|
+
login = auth.add_parser(
|
|
85
|
+
"login",
|
|
86
|
+
help="发起或完成浏览器授权",
|
|
87
|
+
description="首次返回网址并退出;用户授权后再次调用领取。请求十分钟过期,登录固定有效三十天。",
|
|
88
|
+
)
|
|
89
|
+
login.add_argument(
|
|
90
|
+
"--name", default=platform.node() or "CLI", help="网页显示的设备名称,最多 80 字"
|
|
91
|
+
)
|
|
92
|
+
login.add_argument(
|
|
93
|
+
"--credential-store",
|
|
94
|
+
choices=["keyring", "file"],
|
|
95
|
+
help="默认系统凭据管理器;用户明确同意后才能选择 file,不自动降级",
|
|
96
|
+
)
|
|
97
|
+
auth.add_parser("status", help="查看账号、到期时间和存储方式,不输出凭据")
|
|
98
|
+
auth.add_parser("logout", help="服务端撤销当前授权后清除本地凭据;网络失败时保留以便重试")
|
|
99
|
+
groups.add_parser("overview", help="普通用户系统概览")
|
|
100
|
+
stations = groups.add_parser("stations", help="发现站点").add_subparsers(
|
|
101
|
+
dest="action", required=True
|
|
102
|
+
)
|
|
103
|
+
listing = stations.add_parser("list", help="返回稳定 ID 和名称;多项匹配时请用户明确")
|
|
104
|
+
listing.add_argument("--search", help="名称包含搜索")
|
|
105
|
+
observations = groups.add_parser("observations", help="小时观测").add_subparsers(
|
|
106
|
+
dest="action", required=True
|
|
107
|
+
)
|
|
108
|
+
query = observations.add_parser(
|
|
109
|
+
"query",
|
|
110
|
+
help="单站点小时数据",
|
|
111
|
+
description=DATA_HELP,
|
|
112
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
113
|
+
)
|
|
114
|
+
add_range(query)
|
|
115
|
+
add_output(query, data=True)
|
|
116
|
+
reports = groups.add_parser("reports", help="Excel 报表").add_subparsers(
|
|
117
|
+
dest="action", required=True
|
|
118
|
+
)
|
|
119
|
+
download = reports.add_parser(
|
|
120
|
+
"download",
|
|
121
|
+
help="单站点 ZIP,包含小时、日数据 Excel 及完整性说明;始终导出全部要素",
|
|
122
|
+
description=DATA_HELP,
|
|
123
|
+
)
|
|
124
|
+
add_range(download)
|
|
125
|
+
add_output(download)
|
|
126
|
+
favorites = groups.add_parser(
|
|
127
|
+
"favorites", help="本人收藏,保存站点和范围;查看时跟随最新修订"
|
|
128
|
+
).add_subparsers(dest="action", required=True)
|
|
129
|
+
listing = favorites.add_parser("list", help="分页列表,返回 items/total/page/page_size")
|
|
130
|
+
listing.add_argument("--page", type=int, default=1)
|
|
131
|
+
listing.add_argument("--page-size", type=int, default=20, help="1–100,默认 20")
|
|
132
|
+
create = favorites.add_parser(
|
|
133
|
+
"create", help="收藏单站点范围;重复创建返回原 ID 且不覆盖名称备注"
|
|
134
|
+
)
|
|
135
|
+
add_range(create)
|
|
136
|
+
create.add_argument("--name", default="", help="可选名称,最多 200 字")
|
|
137
|
+
create.add_argument("--notes", default="", help="可选备注,最多 2000 字")
|
|
138
|
+
for action, help_text in [
|
|
139
|
+
("show", "收藏详情,不含小时数据"),
|
|
140
|
+
("update", "只修改显式给出的名称备注,空字符串表示清空"),
|
|
141
|
+
("cancel", "取消收藏,不删除观测数据;需要用户明确指令及 --yes"),
|
|
142
|
+
("data", DATA_HELP),
|
|
143
|
+
("download", "下载收藏单站点范围的最新 Excel ZIP"),
|
|
144
|
+
]:
|
|
145
|
+
command = favorites.add_parser(
|
|
146
|
+
action, description=help_text, help=help_text.splitlines()[0]
|
|
147
|
+
)
|
|
148
|
+
command.add_argument("id", help="收藏 ID")
|
|
149
|
+
if action == "update":
|
|
150
|
+
command.add_argument("--name")
|
|
151
|
+
command.add_argument("--notes")
|
|
152
|
+
if action == "cancel":
|
|
153
|
+
command.add_argument("--yes", action="store_true", help="明确确认取消,不弹出交互提示")
|
|
154
|
+
if action in ("data", "download"):
|
|
155
|
+
add_output(command, data=action == "data")
|
|
156
|
+
return root
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def add_range(command: argparse.ArgumentParser) -> None:
|
|
160
|
+
"""声明单站点和明确时间范围。"""
|
|
161
|
+
command.add_argument("--station", required=True, help="stations list 返回的稳定 ID")
|
|
162
|
+
command.add_argument("--start", required=True, help="起始整点,例如 2026-09-11T00:00:00+08:00")
|
|
163
|
+
command.add_argument("--end", required=True, help="结束整点,包含该小时,跨度最多 366 天")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def add_output(command: argparse.ArgumentParser, *, data: bool = False) -> None:
|
|
167
|
+
"""声明互斥的文件与标准输出方式。"""
|
|
168
|
+
output = command.add_mutually_exclusive_group()
|
|
169
|
+
output.add_argument("--output", help="目标文件路径,存在时拒绝覆盖")
|
|
170
|
+
if data:
|
|
171
|
+
output.add_argument(
|
|
172
|
+
"--stdout", action="store_true", help="完整 JSON 写标准输出,不创建文件"
|
|
173
|
+
)
|
|
174
|
+
command.add_argument(
|
|
175
|
+
"--fields", default=",".join(FIELDS), help="逗号分隔要素:" + ",".join(FIELDS)
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def time_range(args: argparse.Namespace) -> dict[str, str]:
|
|
180
|
+
"""校验与后端相同的上海整点闭区间。"""
|
|
181
|
+
try:
|
|
182
|
+
start, end = datetime.fromisoformat(args.start), datetime.fromisoformat(args.end)
|
|
183
|
+
for value in (start, end):
|
|
184
|
+
if (
|
|
185
|
+
value.utcoffset() != timedelta(hours=8)
|
|
186
|
+
or value.minute
|
|
187
|
+
or value.second
|
|
188
|
+
or value.microsecond
|
|
189
|
+
):
|
|
190
|
+
raise ValueError
|
|
191
|
+
if start > end or end - start > timedelta(days=366):
|
|
192
|
+
raise ValueError
|
|
193
|
+
except ValueError as error:
|
|
194
|
+
raise CliError(
|
|
195
|
+
"INVALID_ARGUMENT", "时间必须为 +08:00 整点,起止有序且跨度不超过 366 天", 2
|
|
196
|
+
) from error
|
|
197
|
+
return {"source_id": args.station, "start_time": start.isoformat(), "end_time": end.isoformat()}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def write_file(args: argparse.Namespace, content: bytes, label: str, suffix: str) -> str:
|
|
201
|
+
"""完整写入临时文件后以不覆盖方式发布结果。"""
|
|
202
|
+
safe_label = "".join(c if c.isascii() and (c.isalnum() or c in "-_") else "-" for c in label)[
|
|
203
|
+
:160
|
|
204
|
+
]
|
|
205
|
+
path = (
|
|
206
|
+
Path(args.output)
|
|
207
|
+
if args.output
|
|
208
|
+
else Path.cwd() / "weather-output" / f"{safe_label}-{uuid4().hex[:12]}.{suffix}"
|
|
209
|
+
)
|
|
210
|
+
path = path.absolute()
|
|
211
|
+
if path.exists() or path.is_symlink():
|
|
212
|
+
raise CliError("FILE_EXISTS", "目标文件已存在,请选择其他路径")
|
|
213
|
+
if not args.output:
|
|
214
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
temporary: Path | None = None
|
|
216
|
+
try:
|
|
217
|
+
with tempfile.NamedTemporaryFile(
|
|
218
|
+
dir=path.parent, prefix=".weather-", delete=False
|
|
219
|
+
) as stream:
|
|
220
|
+
temporary = Path(stream.name)
|
|
221
|
+
stream.write(content)
|
|
222
|
+
stream.flush()
|
|
223
|
+
os.fsync(stream.fileno())
|
|
224
|
+
os.link(temporary, path)
|
|
225
|
+
except FileExistsError as error:
|
|
226
|
+
raise CliError("FILE_EXISTS", "目标文件已存在,请选择其他路径") from error
|
|
227
|
+
except OSError as error:
|
|
228
|
+
raise CliError(
|
|
229
|
+
"FILE_WRITE_FAILED", "结果文件保存失败,请检查目录、权限与磁盘空间"
|
|
230
|
+
) from error
|
|
231
|
+
finally:
|
|
232
|
+
if temporary is not None:
|
|
233
|
+
temporary.unlink(missing_ok=True)
|
|
234
|
+
return str(path)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def query_data(args: argparse.Namespace, api: Api, period: dict[str, str]) -> dict[str, Any]:
|
|
238
|
+
"""保留逐小时值并移除覆盖标记和重复说明。"""
|
|
239
|
+
fields = list(dict.fromkeys(args.fields.split(",")))
|
|
240
|
+
if not fields or any(field not in FIELDS for field in fields):
|
|
241
|
+
raise CliError("INVALID_ARGUMENT", "未知气象要素,请查看 --help", 2)
|
|
242
|
+
response = api.request("GET", "/api/observation-trends", params=period)
|
|
243
|
+
data = {key: response[key] for key in ("source", "start_time", "end_time")}
|
|
244
|
+
data["points"] = [
|
|
245
|
+
{"observed_at": row["observed_at"], **{field: row[field] for field in fields}}
|
|
246
|
+
for row in response["points"]
|
|
247
|
+
]
|
|
248
|
+
if args.stdout:
|
|
249
|
+
return data
|
|
250
|
+
label = f"{period['source_id']}-{period['start_time']}-{period['end_time']}"
|
|
251
|
+
path = write_file(
|
|
252
|
+
args,
|
|
253
|
+
json.dumps({"schema_version": "1", **data}, ensure_ascii=False).encode(),
|
|
254
|
+
label,
|
|
255
|
+
"json",
|
|
256
|
+
)
|
|
257
|
+
return {"path": path, "record_count": len(data["points"])}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def execute(args: argparse.Namespace, api: Api) -> dict[str, Any] | list[Any]:
|
|
261
|
+
"""执行已校验的普通用户命令。"""
|
|
262
|
+
if getattr(args, "output", None) and (
|
|
263
|
+
Path(args.output).exists() or Path(args.output).is_symlink()
|
|
264
|
+
):
|
|
265
|
+
raise CliError("FILE_EXISTS", "目标文件已存在,请选择其他路径")
|
|
266
|
+
if args.group == "overview":
|
|
267
|
+
return api.request("GET", "/api/dashboard") # type: ignore[no-any-return]
|
|
268
|
+
if args.group == "stations":
|
|
269
|
+
sources = api.request("GET", "/api/observation-trends/sources")
|
|
270
|
+
return [
|
|
271
|
+
item
|
|
272
|
+
for item in sources
|
|
273
|
+
if not args.search or args.search.casefold() in item["name"].casefold()
|
|
274
|
+
]
|
|
275
|
+
if args.group in ("observations", "reports"):
|
|
276
|
+
period = time_range(args)
|
|
277
|
+
if args.group == "observations":
|
|
278
|
+
return query_data(args, api, period)
|
|
279
|
+
content = api.request("POST", "/api/reports/automatic-station", body=period, binary=True)
|
|
280
|
+
return {"path": write_file(args, content, f"{args.station}-{args.start}-{args.end}", "zip")}
|
|
281
|
+
base = "/api/report-favorites"
|
|
282
|
+
if args.action == "list":
|
|
283
|
+
if args.page < 1 or not 1 <= args.page_size <= 100:
|
|
284
|
+
raise CliError("INVALID_ARGUMENT", "页码至少 1,每页条数为 1–100", 2)
|
|
285
|
+
return api.request("GET", base, params={"page": args.page, "page_size": args.page_size}) # type: ignore[no-any-return]
|
|
286
|
+
if args.action == "create":
|
|
287
|
+
return cast(
|
|
288
|
+
dict[str, Any],
|
|
289
|
+
api.request(
|
|
290
|
+
"POST", base, body={**time_range(args), "name": args.name, "notes": args.notes}
|
|
291
|
+
),
|
|
292
|
+
)
|
|
293
|
+
path = base + "/" + quote(args.id, safe="")
|
|
294
|
+
if args.action == "show":
|
|
295
|
+
return api.request("GET", path) # type: ignore[no-any-return]
|
|
296
|
+
if args.action == "update":
|
|
297
|
+
changes = {
|
|
298
|
+
key: getattr(args, key) for key in ("name", "notes") if getattr(args, key) is not None
|
|
299
|
+
}
|
|
300
|
+
if not changes:
|
|
301
|
+
raise CliError("INVALID_ARGUMENT", "至少指定 --name 或 --notes", 2)
|
|
302
|
+
return api.request("PATCH", path, body=changes) # type: ignore[no-any-return]
|
|
303
|
+
if args.action == "cancel":
|
|
304
|
+
if not args.yes:
|
|
305
|
+
raise CliError("CONFIRMATION_REQUIRED", "用户明确同意取消后,请指定收藏 ID 和 --yes")
|
|
306
|
+
api.request("DELETE", path)
|
|
307
|
+
return {"id": args.id, "cancelled": True}
|
|
308
|
+
favorite = api.request("GET", path)
|
|
309
|
+
period = {key: favorite[key] for key in ("source_id", "start_time", "end_time")}
|
|
310
|
+
if args.action == "data":
|
|
311
|
+
return query_data(args, api, period)
|
|
312
|
+
content = api.request("POST", path + "/report", binary=True)
|
|
313
|
+
return {
|
|
314
|
+
"path": write_file(
|
|
315
|
+
args,
|
|
316
|
+
content,
|
|
317
|
+
f"{period['source_id']}-{period['start_time']}-{period['end_time']}",
|
|
318
|
+
"zip",
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def auth_command(
|
|
324
|
+
args: argparse.Namespace, credentials: Credentials, state: dict[str, Any], api: Client
|
|
325
|
+
) -> dict[str, Any]:
|
|
326
|
+
"""处理可恢复的登录与显式撤销。"""
|
|
327
|
+
token = state.get("token")
|
|
328
|
+
if args.action == "status":
|
|
329
|
+
if not token:
|
|
330
|
+
return {
|
|
331
|
+
"status": "pending" if state.get("device_code") else "unauthenticated",
|
|
332
|
+
"storage": credentials.mode,
|
|
333
|
+
}
|
|
334
|
+
result = api.request("GET", "/api/cli/auth/me")
|
|
335
|
+
return {
|
|
336
|
+
"status": "authenticated",
|
|
337
|
+
"username": result["user"]["username"],
|
|
338
|
+
"expires_at": result["expires_at"],
|
|
339
|
+
"storage": credentials.mode,
|
|
340
|
+
}
|
|
341
|
+
if args.action == "logout":
|
|
342
|
+
if token:
|
|
343
|
+
try:
|
|
344
|
+
api.request("POST", "/api/cli/auth/logout")
|
|
345
|
+
except CliError as error:
|
|
346
|
+
if error.code != "AUTH_REQUIRED":
|
|
347
|
+
raise
|
|
348
|
+
credentials.save({})
|
|
349
|
+
return {"status": "logged_out"}
|
|
350
|
+
if token:
|
|
351
|
+
try:
|
|
352
|
+
result = api.request("GET", "/api/cli/auth/me")
|
|
353
|
+
return {
|
|
354
|
+
"status": "authenticated",
|
|
355
|
+
"username": result["user"]["username"],
|
|
356
|
+
"expires_at": result["expires_at"],
|
|
357
|
+
"storage": credentials.mode,
|
|
358
|
+
}
|
|
359
|
+
except CliError as error:
|
|
360
|
+
if error.code != "AUTH_REQUIRED":
|
|
361
|
+
raise
|
|
362
|
+
state = {}
|
|
363
|
+
credentials.save(state)
|
|
364
|
+
api.token = None
|
|
365
|
+
if not 1 <= len(args.name.strip()) <= 80:
|
|
366
|
+
raise CliError("INVALID_ARGUMENT", "设备名称长度必须为 1–80", 2)
|
|
367
|
+
if state.get("expires_at") and datetime.fromisoformat(state["expires_at"]) <= datetime.now(UTC):
|
|
368
|
+
state = {}
|
|
369
|
+
if not state.get("device_code"):
|
|
370
|
+
state = {"device_code": secrets.token_urlsafe(32)}
|
|
371
|
+
credentials.save(state)
|
|
372
|
+
if not state.get("code"):
|
|
373
|
+
started = api.request(
|
|
374
|
+
"POST",
|
|
375
|
+
"/api/cli/auth/requests",
|
|
376
|
+
body={
|
|
377
|
+
"device_hash": hashlib.sha256(state["device_code"].encode()).hexdigest(),
|
|
378
|
+
"name": args.name.strip(),
|
|
379
|
+
},
|
|
380
|
+
)
|
|
381
|
+
state.update(started)
|
|
382
|
+
credentials.save(state)
|
|
383
|
+
else:
|
|
384
|
+
result = api.request(
|
|
385
|
+
"POST", "/api/cli/auth/token", body={"device_code": state["device_code"]}
|
|
386
|
+
)
|
|
387
|
+
if result["status"] == "authorized":
|
|
388
|
+
credentials.save(
|
|
389
|
+
{
|
|
390
|
+
"token": result["access_token"],
|
|
391
|
+
"username": result["user"]["username"],
|
|
392
|
+
"expires_at": result["expires_at"],
|
|
393
|
+
}
|
|
394
|
+
)
|
|
395
|
+
return {
|
|
396
|
+
"status": "authenticated",
|
|
397
|
+
"username": result["user"]["username"],
|
|
398
|
+
"expires_at": result["expires_at"],
|
|
399
|
+
"storage": credentials.mode,
|
|
400
|
+
}
|
|
401
|
+
if result["status"] in ("denied", "expired"):
|
|
402
|
+
credentials.save({})
|
|
403
|
+
raise CliError(
|
|
404
|
+
"AUTH_DENIED" if result["status"] == "denied" else "AUTH_REQUEST_EXPIRED",
|
|
405
|
+
"授权未完成,请重新运行 auth login",
|
|
406
|
+
)
|
|
407
|
+
return {
|
|
408
|
+
"status": "pending",
|
|
409
|
+
"authorization_url": api.server + "/cli/authorize?code=" + quote(state["code"]),
|
|
410
|
+
"expires_at": state["expires_at"],
|
|
411
|
+
"next_action": "用户在浏览器批准后,再次运行 weather-observation auth login",
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def main(argv: list[str] | None = None) -> int:
|
|
416
|
+
"""统一结构化输出,默认错误不暴露原始请求和凭据。"""
|
|
417
|
+
# Windows 管道和非 UTF-8 终端也提供一致的中文与 JSON 编码。
|
|
418
|
+
for stream in (sys.stdout, sys.stderr):
|
|
419
|
+
if isinstance(stream, io.TextIOWrapper):
|
|
420
|
+
stream.reconfigure(encoding="utf-8")
|
|
421
|
+
try:
|
|
422
|
+
args = parser().parse_args(argv)
|
|
423
|
+
server = normalize_server(args.server)
|
|
424
|
+
mode = getattr(args, "credential_store", None)
|
|
425
|
+
if args.group == "auth" and args.action == "login" and mode is None:
|
|
426
|
+
mode = "keyring" if not Credentials(server).path.exists() else None
|
|
427
|
+
credentials = Credentials(server, mode)
|
|
428
|
+
data: dict[str, Any] | list[Any]
|
|
429
|
+
with credentials.locked():
|
|
430
|
+
state = credentials.load()
|
|
431
|
+
api = Client(server, state.get("token"))
|
|
432
|
+
if args.group == "auth":
|
|
433
|
+
data = auth_command(args, credentials, state, api)
|
|
434
|
+
else:
|
|
435
|
+
if not state.get("token"):
|
|
436
|
+
raise CliError("AUTH_REQUIRED", "请先运行 auth login 完成浏览器授权")
|
|
437
|
+
data = execute(args, api)
|
|
438
|
+
print(json.dumps({"schema_version": "1", "ok": True, "data": data}, ensure_ascii=False))
|
|
439
|
+
return 0
|
|
440
|
+
except CliError as error:
|
|
441
|
+
print(
|
|
442
|
+
json.dumps(
|
|
443
|
+
{
|
|
444
|
+
"schema_version": "1",
|
|
445
|
+
"ok": False,
|
|
446
|
+
"error": {"code": error.code, "message": str(error)},
|
|
447
|
+
},
|
|
448
|
+
ensure_ascii=False,
|
|
449
|
+
)
|
|
450
|
+
)
|
|
451
|
+
return error.exit_code
|
|
452
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
453
|
+
print(
|
|
454
|
+
json.dumps(
|
|
455
|
+
{
|
|
456
|
+
"schema_version": "1",
|
|
457
|
+
"ok": False,
|
|
458
|
+
"error": {
|
|
459
|
+
"code": "CLIENT_ERROR",
|
|
460
|
+
"message": "客户端无法完成操作,请检查本地权限、输入和服务版本",
|
|
461
|
+
},
|
|
462
|
+
},
|
|
463
|
+
ensure_ascii=False,
|
|
464
|
+
)
|
|
465
|
+
)
|
|
466
|
+
return 1
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
if __name__ == "__main__":
|
|
470
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""用户级凭据存储与并发保护。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .errors import CliError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def config_directory() -> Path:
|
|
20
|
+
"""按平台选择当前用户的配置目录。"""
|
|
21
|
+
if sys.platform == "win32":
|
|
22
|
+
return (
|
|
23
|
+
Path(os.environ.get("APPDATA", str(Path.home() / "AppData/Roaming")))
|
|
24
|
+
/ "weather-observation"
|
|
25
|
+
)
|
|
26
|
+
if sys.platform == "darwin":
|
|
27
|
+
return Path.home() / "Library/Application Support/weather-observation"
|
|
28
|
+
return (
|
|
29
|
+
Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")))
|
|
30
|
+
/ "weather-observation"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def protect(path: Path, *, directory: bool = False) -> None:
|
|
35
|
+
"""限制本地文件权限,Windows 使用当前用户 ACL。"""
|
|
36
|
+
if sys.platform != "win32":
|
|
37
|
+
path.chmod(0o700 if directory else 0o600)
|
|
38
|
+
return
|
|
39
|
+
account = f"{os.environ.get('USERDOMAIN', '')}\\{os.environ.get('USERNAME', '')}".lstrip("\\")
|
|
40
|
+
rights = "(OI)(CI)F" if directory else "F"
|
|
41
|
+
result = subprocess.run(
|
|
42
|
+
["icacls", str(path), "/inheritance:r", "/grant:r", f"{account}:{rights}"],
|
|
43
|
+
capture_output=True,
|
|
44
|
+
check=False,
|
|
45
|
+
)
|
|
46
|
+
if result.returncode != 0:
|
|
47
|
+
raise CliError("CREDENTIAL_STORAGE_UNAVAILABLE", "无法限制凭据文件访问权限")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def atomic_json(path: Path, value: dict[str, Any]) -> None:
|
|
51
|
+
"""以受限临时文件原子更新客户端自己的状态。"""
|
|
52
|
+
temporary: Path | None = None
|
|
53
|
+
try:
|
|
54
|
+
with tempfile.NamedTemporaryFile(dir=path.parent, prefix=".state-", delete=False) as stream:
|
|
55
|
+
temporary = Path(stream.name)
|
|
56
|
+
protect(temporary)
|
|
57
|
+
stream.write(json.dumps(value, ensure_ascii=False).encode())
|
|
58
|
+
stream.flush()
|
|
59
|
+
os.fsync(stream.fileno())
|
|
60
|
+
os.replace(temporary, path)
|
|
61
|
+
finally:
|
|
62
|
+
if temporary is not None and temporary.exists():
|
|
63
|
+
temporary.unlink()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Credentials:
|
|
67
|
+
"""按服务地址隔离登录状态,不静默回退明文存储。"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, server: str, mode: str | None = None, root: Path | None = None) -> None:
|
|
70
|
+
"""初始化路径与显式存储选择。"""
|
|
71
|
+
self.server = server
|
|
72
|
+
self.root = root or config_directory()
|
|
73
|
+
self.key = hashlib.sha256(server.encode()).hexdigest()
|
|
74
|
+
self.path = self.root / f"{self.key}.json"
|
|
75
|
+
self.mode = mode
|
|
76
|
+
|
|
77
|
+
@contextmanager
|
|
78
|
+
def locked(self) -> Iterator[None]:
|
|
79
|
+
"""同一服务的授权变更只允许一个客户端执行。"""
|
|
80
|
+
self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
81
|
+
protect(self.root, directory=True)
|
|
82
|
+
lock_path = self.root / f"{self.key}.lock"
|
|
83
|
+
with lock_path.open("a+b") as stream:
|
|
84
|
+
protect(lock_path)
|
|
85
|
+
try:
|
|
86
|
+
if sys.platform == "win32":
|
|
87
|
+
import msvcrt
|
|
88
|
+
|
|
89
|
+
stream.write(b"0")
|
|
90
|
+
stream.flush()
|
|
91
|
+
stream.seek(0)
|
|
92
|
+
msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1)
|
|
93
|
+
else:
|
|
94
|
+
import fcntl
|
|
95
|
+
|
|
96
|
+
fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
97
|
+
except OSError as error:
|
|
98
|
+
raise CliError("CLI_BUSY", "另一个命令正在使用此登录状态,请稍后重试") from error
|
|
99
|
+
try:
|
|
100
|
+
yield
|
|
101
|
+
finally:
|
|
102
|
+
if sys.platform == "win32":
|
|
103
|
+
stream.seek(0)
|
|
104
|
+
msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
|
|
105
|
+
else:
|
|
106
|
+
fcntl.flock(stream, fcntl.LOCK_UN)
|
|
107
|
+
|
|
108
|
+
def backend(self) -> Any:
|
|
109
|
+
"""只使用系统凭据后端,拒绝第三方明文后端。"""
|
|
110
|
+
try:
|
|
111
|
+
import keyring
|
|
112
|
+
|
|
113
|
+
backend: Any = keyring.get_keyring()
|
|
114
|
+
if type(backend).__module__ == "keyring.backends.chainer":
|
|
115
|
+
candidates = backend.backends
|
|
116
|
+
else:
|
|
117
|
+
candidates = [backend]
|
|
118
|
+
allowed = (
|
|
119
|
+
"keyring.backends.macOS",
|
|
120
|
+
"keyring.backends.Windows",
|
|
121
|
+
"keyring.backends.SecretService",
|
|
122
|
+
"keyring.backends.kwallet",
|
|
123
|
+
)
|
|
124
|
+
for candidate in candidates:
|
|
125
|
+
if type(candidate).__module__.startswith(allowed) and candidate.priority > 0:
|
|
126
|
+
return candidate
|
|
127
|
+
except Exception as error:
|
|
128
|
+
raise CliError(
|
|
129
|
+
"CREDENTIAL_STORAGE_UNAVAILABLE",
|
|
130
|
+
"系统凭据管理器不可用;用户明确同意文件存储后,"
|
|
131
|
+
"可运行 auth login --credential-store file",
|
|
132
|
+
) from error
|
|
133
|
+
raise CliError(
|
|
134
|
+
"CREDENTIAL_STORAGE_UNAVAILABLE",
|
|
135
|
+
"系统凭据管理器不可用;用户明确同意文件存储后,"
|
|
136
|
+
"可运行 auth login --credential-store file",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def load(self) -> dict[str, Any]:
|
|
140
|
+
"""读取本服务状态,不在错误中暴露内容。"""
|
|
141
|
+
try:
|
|
142
|
+
marker = json.loads(self.path.read_text(encoding="utf-8")) if self.path.exists() else {}
|
|
143
|
+
if not marker and self.mode is None:
|
|
144
|
+
return {}
|
|
145
|
+
selected = marker.get("storage", self.mode or "keyring")
|
|
146
|
+
if self.mode and marker and self.mode != selected:
|
|
147
|
+
raise CliError("STORAGE_CONFLICT", "请先退出登录再更换凭据存储方式")
|
|
148
|
+
self.mode = selected
|
|
149
|
+
if selected == "file":
|
|
150
|
+
return dict(marker.get("state", {}))
|
|
151
|
+
value = self.backend().get_password("weather-observation", self.key)
|
|
152
|
+
return json.loads(value) if value else {}
|
|
153
|
+
except CliError:
|
|
154
|
+
raise
|
|
155
|
+
except Exception as error:
|
|
156
|
+
raise CliError("CREDENTIAL_STORAGE_UNAVAILABLE", "无法读取本地登录状态") from error
|
|
157
|
+
|
|
158
|
+
def save(self, state: dict[str, Any]) -> None:
|
|
159
|
+
"""保存已选存储中的状态,系统存储失败时不降级。"""
|
|
160
|
+
try:
|
|
161
|
+
if self.mode == "file":
|
|
162
|
+
atomic_json(self.path, {"storage": "file", "state": state})
|
|
163
|
+
else:
|
|
164
|
+
self.backend().set_password("weather-observation", self.key, json.dumps(state))
|
|
165
|
+
atomic_json(self.path, {"storage": "keyring"})
|
|
166
|
+
except CliError:
|
|
167
|
+
raise
|
|
168
|
+
except Exception as error:
|
|
169
|
+
raise CliError(
|
|
170
|
+
"CREDENTIAL_STORAGE_UNAVAILABLE", "无法保存登录状态,可重新调用登录重试"
|
|
171
|
+
) from error
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: weather-observation-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 面向 AI 智能体的深圳气象观测客户端
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: httpx<1,>=0.28
|
|
7
|
+
Requires-Dist: keyring<26,>=25
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# weather-observation CLI
|
|
11
|
+
|
|
12
|
+
供 AI 智能体查询深圳气象观测、下载 Excel 报表及管理本人收藏的独立客户端。
|
|
13
|
+
默认连接 `https://weather.cavonxx.com`,只使用普通用户业务能力。
|
|
14
|
+
|
|
15
|
+
推荐通过 uv 安装:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
uv tool install weather-observation-cli
|
|
19
|
+
weather-observation --help
|
|
20
|
+
weather-observation auth login
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
用户在返回的网址中使用已有账号批准,然后再次运行 `auth login`。请求十分钟有效;
|
|
24
|
+
成功登录固定三十天,到期重新授权。无浏览器的服务器可以在其他设备打开网址。
|
|
25
|
+
默认使用系统凭据管理器;不可用时先让用户明确选择,再运行:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
weather-observation auth login --credential-store file
|
|
29
|
+
weather-observation auth status
|
|
30
|
+
weather-observation auth logout
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`file` 使用当前用户私有配置目录,权限限制不是加密,也不能隔离同一用户运行的其他进程。
|
|
34
|
+
macOS 使用 `~/Library/Application Support/weather-observation/`,Linux 使用
|
|
35
|
+
`$XDG_CONFIG_HOME/weather-observation/`(默认 `~/.config/weather-observation/`),
|
|
36
|
+
Windows 使用 `%APPDATA%/weather-observation/`。凭据按服务源隔离。
|
|
37
|
+
退出时先撤销服务端授权;网络失败保留本地状态以便重试。也可在网页“CLI 授权”撤销。
|
|
38
|
+
|
|
39
|
+
## AI 使用顺序
|
|
40
|
+
|
|
41
|
+
1. 读取对应命令的 `--help`,用 `auth status` 检查身份;授权网址只交给用户本人。
|
|
42
|
+
2. `stations list --search 名称` 查找稳定 ID。多项匹配时请用户明确,不自动选首项。
|
|
43
|
+
3. 将自然语言范围转为带 `+08:00` 的 ISO 8601 整点。首尾包含,跨度最多 366 天。
|
|
44
|
+
4. 单站点调用查询、下载或收藏命令;多站点分别调用。
|
|
45
|
+
5. 默认返回绝对文件路径,使用代码读取完整 JSON 后分析。文件属于实际运行环境。
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
weather-observation observations query --station <ID> \
|
|
49
|
+
--start 2026-09-11T00:00:00+08:00 --end 2026-09-11T23:00:00+08:00 \
|
|
50
|
+
--fields temperature,hourly_rain
|
|
51
|
+
weather-observation reports download --station <ID> \
|
|
52
|
+
--start 2026-09-11T00:00:00+08:00 --end 2026-09-11T23:00:00+08:00
|
|
53
|
+
weather-observation favorites list
|
|
54
|
+
weather-observation favorites show <ID>
|
|
55
|
+
weather-observation favorites data <ID>
|
|
56
|
+
weather-observation favorites download <ID>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
要素为 `temperature`(℃)、`humidity`(%)、`wind_speed`(m/s)、`hourly_rain`(mm)、
|
|
60
|
+
`pressure`(hPa)。默认全部,可用 `--fields` 筛选。`null` 表示缺测,`0` 是真实零值。
|
|
61
|
+
每小时保留记录,记录数不等于有效值数;不插值、补零、抽样或默认附带缺测统计。
|
|
62
|
+
|
|
63
|
+
JSON、ZIP 默认保存到执行目录的 `weather-output/`。`--output` 指定文件;已有文件报错,
|
|
64
|
+
不提供覆盖选项。查询 `--stdout` 将完整 JSON 输出到终端,与 `--output` 互斥。
|
|
65
|
+
Excel ZIP 始终包含完整要素的小时、日数据工作簿及完整性说明。
|
|
66
|
+
|
|
67
|
+
收藏保存站点和时间范围,不冻结数据;详情用 `show`,数据用 `data`。修改只提交明确给出的
|
|
68
|
+
`--name`、`--notes`。只有用户明确要求取消指定收藏时才调用 `favorites cancel <ID> --yes`。
|
|
69
|
+
重复创建返回原 ID,不覆盖原名称备注;写请求网络失败时应先检查状态,不盲目重放。
|
|
70
|
+
|
|
71
|
+
所有业务命令输出 `schema_version`、`ok`,成功包含 `data`,失败包含 `error.code/message`。
|
|
72
|
+
退出码:0 成功,2 参数错误,1 其他失败。等待浏览器批准属于成功返回的 pending 状态。
|
|
73
|
+
日志和进度不进入 JSON。帮助不需要登录。服务支持 `--server` 开发覆盖参数(放在子命令前),
|
|
74
|
+
只允许 HTTPS 或本机 HTTP,不跟随服务重定向。
|
|
75
|
+
|
|
76
|
+
## 开发验证
|
|
77
|
+
|
|
78
|
+
Python 3.11 及以上。`uv sync` 后执行 `uv run pytest`、`uv run ruff check src tests`、
|
|
79
|
+
`uv run ruff format --check src tests`、`uv run mypy src tests`,使用 `uv build` 构建。
|
|
80
|
+
macOS、Windows、Linux 为目标平台;实际验证记录见仓库设计文档,未经验证的平台不宣称已通过。
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
weather_observation_cli/__init__.py,sha256=qJQml9PnGMGHsuQ7R_tN-hXLWTSRfGFGYgDS1Qzurz0,60
|
|
2
|
+
weather_observation_cli/client.py,sha256=JnmbypqGqlF4gJMJL8x1ZM6TM-2AaEzzZZWGbyOU1ZM,3684
|
|
3
|
+
weather_observation_cli/errors.py,sha256=0hfOoAw7YMUokelhz2X9NnQk4Rcm7cCv9DONZMNZi2c,339
|
|
4
|
+
weather_observation_cli/main.py,sha256=KCyGD_wMsOvfCCWVbfcJKEl4yEJVJkuW8KLYkwIJyi4,19685
|
|
5
|
+
weather_observation_cli/storage.py,sha256=DCv6MZ4m0VNPbvSoOmqG5o3O-IuzKHumEz4poTGhCTY,6724
|
|
6
|
+
weather_observation_cli-0.1.0.dist-info/METADATA,sha256=g4If9eWi-H_SUpM9rEybv8x5yAPbGTauknyb9agJ-Ho,4251
|
|
7
|
+
weather_observation_cli-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
8
|
+
weather_observation_cli-0.1.0.dist-info/entry_points.txt,sha256=LkXBpbIz4SQKyMn_KxxyFp0KqzVq-ogMDN0CLF0qCjI,74
|
|
9
|
+
weather_observation_cli-0.1.0.dist-info/RECORD,,
|