dataapp-resource-proxy 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.
- dataapp_resource_proxy/__init__.py +15 -0
- dataapp_resource_proxy/__main__.py +65 -0
- dataapp_resource_proxy/_local_config.py +286 -0
- dataapp_resource_proxy/client.py +1813 -0
- dataapp_resource_proxy/errors.py +16 -0
- dataapp_resource_proxy/handles.py +199 -0
- dataapp_resource_proxy/py.typed +0 -0
- dataapp_resource_proxy-0.1.0.dist-info/METADATA +6 -0
- dataapp_resource_proxy-0.1.0.dist-info/RECORD +10 -0
- dataapp_resource_proxy-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Client for DataOS managed runtime resources."""
|
|
2
|
+
|
|
3
|
+
from .client import ResourceProxy, ResourceTransport
|
|
4
|
+
from .errors import ResourceProxyError
|
|
5
|
+
from .handles import BrowserResource, CaptchaPluginGrant, CaptchaTask, ProxyResource
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"BrowserResource",
|
|
9
|
+
"CaptchaTask",
|
|
10
|
+
"CaptchaPluginGrant",
|
|
11
|
+
"ProxyResource",
|
|
12
|
+
"ResourceProxy",
|
|
13
|
+
"ResourceProxyError",
|
|
14
|
+
"ResourceTransport",
|
|
15
|
+
]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Command-line helpers for local DataApp resource configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import getpass
|
|
7
|
+
import os
|
|
8
|
+
import warnings
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
|
|
11
|
+
from ._local_config import (
|
|
12
|
+
load_project_config,
|
|
13
|
+
resolve_config_environment,
|
|
14
|
+
save_local_config,
|
|
15
|
+
)
|
|
16
|
+
from .errors import ResourceProxyError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _configure() -> int:
|
|
20
|
+
try:
|
|
21
|
+
project = load_project_config(os.environ)
|
|
22
|
+
environment = resolve_config_environment(os.environ, project)
|
|
23
|
+
except ResourceProxyError as error:
|
|
24
|
+
print(f"配置错误:{error}")
|
|
25
|
+
return 2
|
|
26
|
+
if environment is None:
|
|
27
|
+
print(
|
|
28
|
+
"未找到当前项目的 .dataapp-sdk.json。请在下载的 SDK 项目根目录运行,"
|
|
29
|
+
"或为独立项目显式设置 DATAAPP_ENVIRONMENT。"
|
|
30
|
+
)
|
|
31
|
+
return 2
|
|
32
|
+
print("平台托管 IP 代理和验证码服务会按实际用量产生资源费用。")
|
|
33
|
+
if project is not None:
|
|
34
|
+
print(f"请从当前平台账户中心获取 API Key:{project.api_keys_url}")
|
|
35
|
+
answer = input("确认在本地调试中使用这些收费资源?[y/N] ").strip().lower()
|
|
36
|
+
if answer not in {"y", "yes"}:
|
|
37
|
+
print("未保存配置,也不会发起任何托管资源请求。")
|
|
38
|
+
return 1
|
|
39
|
+
try:
|
|
40
|
+
with warnings.catch_warnings():
|
|
41
|
+
warnings.simplefilter("error", getpass.GetPassWarning)
|
|
42
|
+
api_key = getpass.getpass("请粘贴账户中心的 API Key(输入不会显示):").strip()
|
|
43
|
+
except (EOFError, KeyboardInterrupt, getpass.GetPassWarning):
|
|
44
|
+
print("当前终端无法安全隐藏输入;未保存配置。请在交互式终端重新运行。")
|
|
45
|
+
return 2
|
|
46
|
+
if not api_key:
|
|
47
|
+
print("API Key 不能为空;未保存配置。")
|
|
48
|
+
return 2
|
|
49
|
+
path = save_local_config(api_key, environment=environment)
|
|
50
|
+
print(f"配置已安全保存到 {path},文件权限已设置为 600。")
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
55
|
+
parser = argparse.ArgumentParser(prog="python -m dataapp_resource_proxy")
|
|
56
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
57
|
+
commands.add_parser("configure", help="configure local managed resources securely")
|
|
58
|
+
options = parser.parse_args(argv)
|
|
59
|
+
if options.command == "configure":
|
|
60
|
+
return _configure()
|
|
61
|
+
return 2
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Project-bound defaults and private local managed-resource credentials."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
import tempfile
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
from .errors import ResourceProxyError
|
|
16
|
+
|
|
17
|
+
_CONFIG_ENV = "DATAAPP_LOCAL_CONFIG"
|
|
18
|
+
_CONFIG_FILENAME = "local-resources.json"
|
|
19
|
+
_PROJECT_CONFIG_FILENAME = ".dataapp-sdk.json"
|
|
20
|
+
_PROJECT_CONFIG_MAX_BYTES = 64 * 1024
|
|
21
|
+
_LEGACY_CONFIG_KEYS = frozenset({"schema_version", "DATAAPP_API_KEY"})
|
|
22
|
+
_SCOPED_CONFIG_KEYS = frozenset({"schema_version", "environment", "DATAAPP_API_KEY"})
|
|
23
|
+
_PROJECT_CONFIG_KEYS = frozenset(
|
|
24
|
+
{"schema_version", "environment", "api_keys_url", "resource_proxy"}
|
|
25
|
+
)
|
|
26
|
+
_RESOURCE_PROXY_KEYS = frozenset({"domestic_url", "overseas_url"})
|
|
27
|
+
_ENVIRONMENTS = frozenset({"bazhuayu_pre", "bazhuayu", "octoparse_pre", "octoparse"})
|
|
28
|
+
_PROFILE_ENVIRONMENTS = {
|
|
29
|
+
"bazhuayu_pre": "bazhuayu_pre",
|
|
30
|
+
"bazhuayu_overseas_pre": "bazhuayu_pre",
|
|
31
|
+
"bazhuayu": "bazhuayu",
|
|
32
|
+
"bazhuayu_overseas": "bazhuayu",
|
|
33
|
+
"octoparse_pre": "octoparse_pre",
|
|
34
|
+
"octoparse": "octoparse",
|
|
35
|
+
}
|
|
36
|
+
_PLATFORM_HOST_SUFFIXES = (".bazhuayu.com", ".octoparse.com")
|
|
37
|
+
_PORTAL_HOST_SUFFIXES = {
|
|
38
|
+
"bazhuayu": ".bazhuayu.com",
|
|
39
|
+
"octoparse": ".octoparse.com",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class ProjectSdkConfig:
|
|
45
|
+
"""Non-secret defaults shipped with one downloaded project."""
|
|
46
|
+
|
|
47
|
+
environment: str
|
|
48
|
+
api_keys_url: str
|
|
49
|
+
domestic_url: str
|
|
50
|
+
overseas_url: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_project_config(
|
|
54
|
+
environ: Mapping[str, str] | None = None,
|
|
55
|
+
*,
|
|
56
|
+
directory: Path | None = None,
|
|
57
|
+
) -> ProjectSdkConfig | None:
|
|
58
|
+
"""Load strict project defaults from the current project root, if present."""
|
|
59
|
+
del environ # Reserved for a future explicit, platform-controlled path.
|
|
60
|
+
path = (Path.cwd() if directory is None else directory) / _PROJECT_CONFIG_FILENAME
|
|
61
|
+
try:
|
|
62
|
+
info = path.lstat()
|
|
63
|
+
except FileNotFoundError:
|
|
64
|
+
return None
|
|
65
|
+
except OSError as error:
|
|
66
|
+
raise _configuration_error(
|
|
67
|
+
f"cannot inspect {_PROJECT_CONFIG_FILENAME} ({type(error).__name__})"
|
|
68
|
+
) from None
|
|
69
|
+
if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
|
|
70
|
+
raise _configuration_error(f"{_PROJECT_CONFIG_FILENAME} must be a regular file")
|
|
71
|
+
if info.st_size > _PROJECT_CONFIG_MAX_BYTES:
|
|
72
|
+
raise _configuration_error(f"{_PROJECT_CONFIG_FILENAME} is too large")
|
|
73
|
+
try:
|
|
74
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
75
|
+
except (OSError, UnicodeError, ValueError) as error:
|
|
76
|
+
raise _configuration_error(
|
|
77
|
+
f"cannot load {_PROJECT_CONFIG_FILENAME} ({type(error).__name__})"
|
|
78
|
+
) from None
|
|
79
|
+
if (
|
|
80
|
+
not isinstance(payload, dict)
|
|
81
|
+
or set(payload) != _PROJECT_CONFIG_KEYS
|
|
82
|
+
or payload.get("schema_version") != 1
|
|
83
|
+
or payload.get("environment") not in _ENVIRONMENTS
|
|
84
|
+
or not isinstance(payload.get("api_keys_url"), str)
|
|
85
|
+
or not isinstance(payload.get("resource_proxy"), dict)
|
|
86
|
+
or set(payload["resource_proxy"]) != _RESOURCE_PROXY_KEYS
|
|
87
|
+
or not isinstance(payload["resource_proxy"].get("domestic_url"), str)
|
|
88
|
+
or not isinstance(payload["resource_proxy"].get("overseas_url"), str)
|
|
89
|
+
):
|
|
90
|
+
raise _configuration_error(f"{_PROJECT_CONFIG_FILENAME} has an unsupported format")
|
|
91
|
+
environment = payload["environment"]
|
|
92
|
+
api_keys_url = _validated_platform_url(
|
|
93
|
+
payload["api_keys_url"], environment=environment, api_keys=True
|
|
94
|
+
)
|
|
95
|
+
domestic_url = _validated_platform_url(
|
|
96
|
+
payload["resource_proxy"]["domestic_url"], environment=environment
|
|
97
|
+
)
|
|
98
|
+
overseas_url = _validated_platform_url(
|
|
99
|
+
payload["resource_proxy"]["overseas_url"], environment=environment
|
|
100
|
+
)
|
|
101
|
+
return ProjectSdkConfig(
|
|
102
|
+
environment=environment,
|
|
103
|
+
api_keys_url=api_keys_url,
|
|
104
|
+
domestic_url=domestic_url,
|
|
105
|
+
overseas_url=overseas_url,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def resolve_config_environment(
|
|
110
|
+
environ: Mapping[str, str], project: ProjectSdkConfig | None
|
|
111
|
+
) -> str | None:
|
|
112
|
+
"""Resolve and validate the canonical account environment for local config."""
|
|
113
|
+
current = environ.get("DATAAPP_ENVIRONMENT") or None
|
|
114
|
+
legacy = environ.get("DATAOS_ENVIRONMENT") or None
|
|
115
|
+
if current and legacy and current != legacy:
|
|
116
|
+
raise _configuration_error(
|
|
117
|
+
"DATAAPP_ENVIRONMENT conflicts with deprecated DATAOS_ENVIRONMENT"
|
|
118
|
+
)
|
|
119
|
+
profile = current or legacy
|
|
120
|
+
if profile is not None and profile not in _PROFILE_ENVIRONMENTS:
|
|
121
|
+
raise _configuration_error("DATAAPP_ENVIRONMENT is not supported")
|
|
122
|
+
explicit = _PROFILE_ENVIRONMENTS.get(profile) if profile is not None else None
|
|
123
|
+
if project is not None and explicit is not None and explicit != project.environment:
|
|
124
|
+
raise _configuration_error(
|
|
125
|
+
"DATAAPP_ENVIRONMENT conflicts with the downloaded project environment"
|
|
126
|
+
)
|
|
127
|
+
return project.environment if project is not None else explicit
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def local_config_path(
|
|
131
|
+
environ: Mapping[str, str] | None = None, *, environment: str | None = None
|
|
132
|
+
) -> Path:
|
|
133
|
+
"""Return the private config path without creating it."""
|
|
134
|
+
env = os.environ if environ is None else environ
|
|
135
|
+
explicit = env.get(_CONFIG_ENV)
|
|
136
|
+
if explicit:
|
|
137
|
+
return Path(explicit).expanduser()
|
|
138
|
+
config_root = env.get("XDG_CONFIG_HOME")
|
|
139
|
+
root = Path(config_root).expanduser() if config_root else Path.home() / ".config"
|
|
140
|
+
parent = root / "bazhuayu-dataapp"
|
|
141
|
+
return (
|
|
142
|
+
parent / environment / _CONFIG_FILENAME
|
|
143
|
+
if environment is not None
|
|
144
|
+
else parent / _CONFIG_FILENAME
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def load_local_config(
|
|
149
|
+
environ: Mapping[str, str], *, environment: str | None = None
|
|
150
|
+
) -> dict[str, str]:
|
|
151
|
+
"""Load one environment-bound value-only config with private permissions."""
|
|
152
|
+
path = local_config_path(environ, environment=environment)
|
|
153
|
+
try:
|
|
154
|
+
info = path.lstat()
|
|
155
|
+
except FileNotFoundError:
|
|
156
|
+
return {}
|
|
157
|
+
except OSError as error:
|
|
158
|
+
raise _configuration_error(
|
|
159
|
+
f"cannot read local managed-resource configuration ({type(error).__name__})"
|
|
160
|
+
) from None
|
|
161
|
+
if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
|
|
162
|
+
raise _configuration_error("local managed-resource configuration must be a regular file")
|
|
163
|
+
if stat.S_IMODE(info.st_mode) & 0o077:
|
|
164
|
+
raise _configuration_error(
|
|
165
|
+
"local managed-resource configuration is not private; run "
|
|
166
|
+
"`python -m dataapp_resource_proxy configure` to repair it"
|
|
167
|
+
)
|
|
168
|
+
try:
|
|
169
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
170
|
+
except (OSError, UnicodeError, ValueError) as error:
|
|
171
|
+
raise _configuration_error(
|
|
172
|
+
f"cannot load local managed-resource configuration ({type(error).__name__})"
|
|
173
|
+
) from None
|
|
174
|
+
if environment is None:
|
|
175
|
+
valid = (
|
|
176
|
+
isinstance(payload, dict)
|
|
177
|
+
and set(payload) == _LEGACY_CONFIG_KEYS
|
|
178
|
+
and payload.get("schema_version") == 1
|
|
179
|
+
)
|
|
180
|
+
else:
|
|
181
|
+
valid = (
|
|
182
|
+
isinstance(payload, dict)
|
|
183
|
+
and set(payload) == _SCOPED_CONFIG_KEYS
|
|
184
|
+
and payload.get("schema_version") == 2
|
|
185
|
+
and payload.get("environment") == environment
|
|
186
|
+
)
|
|
187
|
+
if (
|
|
188
|
+
not valid
|
|
189
|
+
or not isinstance(payload.get("DATAAPP_API_KEY"), str)
|
|
190
|
+
or not payload["DATAAPP_API_KEY"].strip()
|
|
191
|
+
):
|
|
192
|
+
raise _configuration_error(
|
|
193
|
+
"local managed-resource configuration does not match the current environment; "
|
|
194
|
+
"run `python -m dataapp_resource_proxy configure` to replace it"
|
|
195
|
+
)
|
|
196
|
+
return {"DATAAPP_API_KEY": payload["DATAAPP_API_KEY"]}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def save_local_config(
|
|
200
|
+
api_key: str,
|
|
201
|
+
environ: Mapping[str, str] | None = None,
|
|
202
|
+
*,
|
|
203
|
+
environment: str | None = None,
|
|
204
|
+
) -> Path:
|
|
205
|
+
"""Atomically store the local API key in a mode-600 environment-bound file."""
|
|
206
|
+
if not isinstance(api_key, str) or not api_key.strip():
|
|
207
|
+
raise ValueError("API Key must not be empty")
|
|
208
|
+
env = os.environ if environ is None else environ
|
|
209
|
+
path = local_config_path(env, environment=environment)
|
|
210
|
+
parent_existed = path.parent.exists()
|
|
211
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
212
|
+
if not env.get(_CONFIG_ENV) and parent_existed:
|
|
213
|
+
os.chmod(path.parent, 0o700)
|
|
214
|
+
payload: dict[str, Any]
|
|
215
|
+
if environment is None:
|
|
216
|
+
payload = {"schema_version": 1, "DATAAPP_API_KEY": api_key.strip()}
|
|
217
|
+
else:
|
|
218
|
+
payload = {
|
|
219
|
+
"schema_version": 2,
|
|
220
|
+
"environment": environment,
|
|
221
|
+
"DATAAPP_API_KEY": api_key.strip(),
|
|
222
|
+
}
|
|
223
|
+
descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent, text=True)
|
|
224
|
+
try:
|
|
225
|
+
os.fchmod(descriptor, 0o600)
|
|
226
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
227
|
+
descriptor = -1
|
|
228
|
+
json.dump(payload, stream, ensure_ascii=False)
|
|
229
|
+
stream.write("\n")
|
|
230
|
+
stream.flush()
|
|
231
|
+
os.fsync(stream.fileno())
|
|
232
|
+
os.replace(temporary, path)
|
|
233
|
+
os.chmod(path, 0o600)
|
|
234
|
+
finally:
|
|
235
|
+
if descriptor >= 0:
|
|
236
|
+
os.close(descriptor)
|
|
237
|
+
try:
|
|
238
|
+
os.unlink(temporary)
|
|
239
|
+
except FileNotFoundError:
|
|
240
|
+
pass
|
|
241
|
+
return path
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _validated_platform_url(value: str, *, environment: str, api_keys: bool = False) -> str:
|
|
245
|
+
raw = value.strip()
|
|
246
|
+
parsed = urlsplit(raw)
|
|
247
|
+
hostname = (parsed.hostname or "").lower()
|
|
248
|
+
try:
|
|
249
|
+
port = parsed.port
|
|
250
|
+
except ValueError:
|
|
251
|
+
port = -1
|
|
252
|
+
if (
|
|
253
|
+
parsed.scheme != "https"
|
|
254
|
+
or not hostname
|
|
255
|
+
or port not in {None, 443}
|
|
256
|
+
or parsed.username is not None
|
|
257
|
+
or parsed.password is not None
|
|
258
|
+
or parsed.query
|
|
259
|
+
or parsed.fragment
|
|
260
|
+
or not any(hostname.endswith(suffix) for suffix in _PLATFORM_HOST_SUFFIXES)
|
|
261
|
+
):
|
|
262
|
+
raise _configuration_error(f"{_PROJECT_CONFIG_FILENAME} contains an unsafe platform URL")
|
|
263
|
+
labels = hostname.split(".")
|
|
264
|
+
is_pre_host = any(label == "pre" or label.startswith("pre-") for label in labels)
|
|
265
|
+
if environment.endswith("_pre") != is_pre_host:
|
|
266
|
+
raise _configuration_error(f"{_PROJECT_CONFIG_FILENAME} mixes pre and production endpoints")
|
|
267
|
+
if api_keys:
|
|
268
|
+
brand = environment.removesuffix("_pre")
|
|
269
|
+
portal_suffix = _PORTAL_HOST_SUFFIXES[brand]
|
|
270
|
+
if hostname != portal_suffix.removeprefix(".") and not hostname.endswith(portal_suffix):
|
|
271
|
+
raise _configuration_error(
|
|
272
|
+
f"{_PROJECT_CONFIG_FILENAME} API Keys URL does not match its environment"
|
|
273
|
+
)
|
|
274
|
+
if parsed.path.rstrip("/") != "/console/account-center/api-keys":
|
|
275
|
+
raise _configuration_error(
|
|
276
|
+
f"{_PROJECT_CONFIG_FILENAME} contains an invalid API Keys URL"
|
|
277
|
+
)
|
|
278
|
+
elif parsed.path not in {"", "/"}:
|
|
279
|
+
raise _configuration_error(
|
|
280
|
+
f"{_PROJECT_CONFIG_FILENAME} contains an invalid Resource Proxy URL"
|
|
281
|
+
)
|
|
282
|
+
return raw.rstrip("/")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _configuration_error(message: str) -> ResourceProxyError:
|
|
286
|
+
return ResourceProxyError(message, code="configuration-error")
|