insightfactory-cli 1.0.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,75 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from if_cli.cli import Options, parse_args
6
+ from if_cli.config import (
7
+ INI_UNSAFE_VALUE,
8
+ empty_section,
9
+ get_profile,
10
+ load_config,
11
+ parse_callback_port,
12
+ resolve_profile_name,
13
+ save_config,
14
+ )
15
+ from if_cli.http import normalize_origin, validate_audience_url
16
+ from if_cli.oauth import login
17
+ from if_cli.runtime import die, format_expiry
18
+
19
+ LOGIN_OPTIONS: Options = {
20
+ "profile": {"type": "string", "short": "p"},
21
+ "host": {"type": "string"},
22
+ "client-id": {"type": "string"},
23
+ "organization": {"type": "string"},
24
+ "audience": {"type": "string"},
25
+ "callback-port": {"type": "string"},
26
+ "no-browser": {"type": "boolean", "default": False},
27
+ }
28
+
29
+
30
+ def login_command(argv: list[str]) -> None:
31
+ values, _positionals = parse_args(argv, options=LOGIN_OPTIONS)
32
+ name = resolve_profile_name(values["profile"])
33
+ has_overrides = any(
34
+ values[key] is not None for key in ("host", "client-id", "organization", "audience", "callback-port")
35
+ )
36
+
37
+ def apply_overrides(config: dict[str, dict[str, str]]) -> dict[str, dict[str, str]]:
38
+ key = "" if name == "DEFAULT" else name
39
+ section = config.setdefault(key, empty_section())
40
+ if values["host"] is not None:
41
+ section["host"] = normalize_origin(values["host"], "host")
42
+ if values["client-id"] is not None:
43
+ if not values["client-id"].strip():
44
+ die("client ID cannot be empty")
45
+ if INI_UNSAFE_VALUE.search(values["client-id"]):
46
+ die("--client-id cannot contain newlines or carriage returns")
47
+ section["client_id"] = values["client-id"]
48
+ if values["organization"] is not None:
49
+ if not values["organization"].strip():
50
+ die("organization cannot be empty")
51
+ if INI_UNSAFE_VALUE.search(values["organization"]):
52
+ die("--organization cannot contain newlines or carriage returns")
53
+ section["organization"] = values["organization"]
54
+ if values["audience"] is not None:
55
+ if INI_UNSAFE_VALUE.search(values["audience"]):
56
+ die("--audience cannot contain newlines or carriage returns")
57
+ section["audience"] = validate_audience_url(values["audience"].strip(), "audience")
58
+ if values["callback-port"] is not None:
59
+ section["callback_port"] = str(parse_callback_port(values["callback-port"]))
60
+ return config
61
+
62
+ profile = get_profile(apply_overrides(load_config()) if has_overrides else load_config(), name)
63
+ login_options: dict[str, str] = {}
64
+ if values["client-id"] is not None:
65
+ login_options["client_id_override"] = values["client-id"]
66
+ if values["organization"] is not None:
67
+ login_options["organization_override"] = values["organization"]
68
+ entry = login(profile, bool(values["no-browser"]), login_options)
69
+ if has_overrides:
70
+ save_config(apply_overrides(load_config()))
71
+ refreshable = "with refresh token" if entry.get("refresh_token") else "NO refresh token"
72
+ sys.stdout.write(
73
+ f"✓ logged in to {profile['host']} (profile '{name}', access token valid until "
74
+ f"{format_expiry(int(entry['expires_at']))}, {refreshable})\n"
75
+ )
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from if_cli.cache import clear_token
6
+ from if_cli.cli import Options, parse_args
7
+ from if_cli.config import get_profile, load_config, resolve_profile_name
8
+
9
+ LOGOUT_OPTIONS: Options = {
10
+ "profile": {"type": "string", "short": "p"},
11
+ }
12
+
13
+
14
+ def logout_command(argv: list[str]) -> None:
15
+ values, _positionals = parse_args(argv, options=LOGOUT_OPTIONS)
16
+ profile = get_profile(load_config(), resolve_profile_name(values["profile"]))
17
+ removed = clear_token(profile["host"])
18
+ sys.stdout.write(
19
+ f"✓ cleared cached token for {profile['host']}\n" if removed else f"no cached token for {profile['host']}\n"
20
+ )
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ import time
6
+ from typing import Literal, TypedDict
7
+
8
+ from if_cli.cache import TokenCache, is_fresh, load_cache
9
+ from if_cli.cli import Options, parse_args
10
+ from if_cli.colour import Colour, colourise
11
+ from if_cli.config import config_file, get_profile, load_config
12
+ from if_cli.runtime import format_expiry
13
+
14
+ TokenStatus = Literal["valid", "expired", "none", "unknown"]
15
+
16
+
17
+ class ProfileTokenSummary(TypedDict, total=False):
18
+ status: TokenStatus
19
+ expires_at: float
20
+ refreshable: bool
21
+ error: str
22
+
23
+
24
+ class ProfileSummary(TypedDict, total=False):
25
+ name: str
26
+ host: str | None
27
+ token: ProfileTokenSummary
28
+ error: str
29
+
30
+
31
+ PROFILES_OPTIONS: Options = {
32
+ "json": {"type": "boolean", "default": False},
33
+ }
34
+
35
+
36
+ def summarize_profiles(
37
+ config: dict[str, dict[str, str]],
38
+ cache: TokenCache,
39
+ cache_error: str | None,
40
+ now: float,
41
+ ) -> list[ProfileSummary]:
42
+ summaries: list[ProfileSummary] = []
43
+ for section in config:
44
+ name = "DEFAULT" if section == "" else section
45
+ try:
46
+ profile = get_profile(config, name)
47
+ except Exception as error:
48
+ summaries.append(
49
+ {
50
+ "name": name,
51
+ "host": config[section].get("host") or None,
52
+ "token": {"status": "unknown"},
53
+ "error": str(error),
54
+ }
55
+ )
56
+ continue
57
+
58
+ entry = cache["tokens"].get(profile["host"])
59
+ token: ProfileTokenSummary
60
+ if cache_error:
61
+ token = {"status": "unknown", "error": cache_error}
62
+ elif not entry:
63
+ token = {"status": "none"}
64
+ else:
65
+ token = {
66
+ "status": "valid" if is_fresh(entry, now) else "expired",
67
+ "expires_at": entry["expires_at"],
68
+ "refreshable": "refresh_token" in entry,
69
+ }
70
+ summaries.append({"name": name, "host": profile["host"], "token": token})
71
+ return summaries
72
+
73
+
74
+ def _describe_token(token: ProfileTokenSummary) -> tuple[str, Colour]:
75
+ status = token["status"]
76
+ if status == "unknown":
77
+ return f"token cache unavailable ({token.get('error')})", "red"
78
+ if status == "none":
79
+ return "no token", "red"
80
+ if status == "valid":
81
+ expiry = "" if token.get("expires_at") is None else f" until {format_expiry(int(token['expires_at']))}"
82
+ refreshable = " (refreshable)" if token.get("refreshable") else ""
83
+ return f"valid{expiry}{refreshable}", "green"
84
+ if status == "expired":
85
+ refreshable = " (refreshable)" if token.get("refreshable") else ""
86
+ return f"expired{refreshable}", "yellow" if token.get("refreshable") else "red"
87
+ return str(status), "red"
88
+
89
+
90
+ def profiles_command(argv: list[str] | None = None) -> None:
91
+ values, _positionals = parse_args(argv or [], options=PROFILES_OPTIONS)
92
+ config = load_config()
93
+ if len(config) == 0:
94
+ if values["json"]:
95
+ sys.stdout.write("[]\n")
96
+ return
97
+ sys.stdout.write(f"no profiles configured in {config_file()}\n")
98
+ return
99
+
100
+ cache: TokenCache = {"version": 1, "tokens": {}}
101
+ cache_error: str | None = None
102
+ try:
103
+ cache = load_cache()
104
+ except Exception as error:
105
+ cache_error = str(error)
106
+
107
+ summaries = summarize_profiles(config, cache, cache_error, time.time())
108
+ if values["json"]:
109
+ sys.stdout.write(f"{json.dumps(summaries, indent=2)}\n")
110
+ return
111
+
112
+ for summary in summaries:
113
+ if summary.get("error"):
114
+ status = f"misconfigured ({summary['error']})"
115
+ colour = "red"
116
+ else:
117
+ status, colour = _describe_token(summary["token"])
118
+ host = summary.get("host") or "<no host>"
119
+ sys.stdout.write(f"{summary['name']:<20} {host:<55} {colourise(status, colour)}\n")
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import getpass
4
+ import math
5
+ import sys
6
+ import time
7
+
8
+ from if_cli.cache import store_tokens
9
+ from if_cli.cli import Options, parse_args
10
+ from if_cli.config import get_profile, load_config, resolve_profile_name
11
+ from if_cli.runtime import die, format_expiry, jwt_exp
12
+
13
+ SET_TOKEN_OPTIONS: Options = {
14
+ "profile": {"type": "string", "short": "p"},
15
+ }
16
+
17
+
18
+ def _read_hidden_token() -> str:
19
+ """Read a token without echoing it, matching the Node CLI's hidden-input behaviour."""
20
+ try:
21
+ if sys.stdin.isatty():
22
+ return getpass.getpass("").strip()
23
+ line = sys.stdin.readline()
24
+ if line == "":
25
+ die("no token provided")
26
+ return line.strip()
27
+ except KeyboardInterrupt:
28
+ try:
29
+ sys.stderr.write("\n")
30
+ except BrokenPipeError:
31
+ pass
32
+ die("token input cancelled")
33
+ except EOFError:
34
+ die("no token provided")
35
+
36
+
37
+ def set_token_command(argv: list[str]) -> None:
38
+ values, _positionals = parse_args(argv, options=SET_TOKEN_OPTIONS)
39
+ profile = get_profile(load_config(), resolve_profile_name(values["profile"]))
40
+ sys.stderr.write("Paste the access token (from Profile → Developer → Copy token), then Enter:\n")
41
+ sys.stderr.flush()
42
+ token = _read_hidden_token()
43
+ if not token:
44
+ die("no token provided")
45
+
46
+ expiry = jwt_exp(token)
47
+ now = math.floor(time.time())
48
+ if expiry is not None and expiry <= now:
49
+ die("the provided access token is already expired")
50
+ entry = store_tokens(
51
+ profile["host"],
52
+ {
53
+ "access_token": token,
54
+ "expires_in": 3600 if expiry is None else expiry - now,
55
+ },
56
+ "",
57
+ None,
58
+ replace=True,
59
+ )
60
+ sys.stdout.write(
61
+ f"✓ stored static token for {profile['host']} (expires {format_expiry(int(entry['expires_at']))})\n"
62
+ )
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import sys
5
+
6
+ from if_cli.cli import Options, parse_args
7
+ from if_cli.config import get_profile, load_config, resolve_profile_name
8
+ from if_cli.oauth import get_valid_token
9
+ from if_cli.runtime import die
10
+
11
+ TOKEN_OPTIONS: Options = {
12
+ "profile": {"type": "string", "short": "p"},
13
+ "env": {"type": "boolean", "default": False},
14
+ "env-name": {"type": "string"},
15
+ }
16
+
17
+
18
+ def shell_quote(value: str) -> str:
19
+ return "'" + value.replace("'", "'\\''") + "'"
20
+
21
+
22
+ def token_command(argv: list[str]) -> None:
23
+ values, _positionals = parse_args(argv, options=TOKEN_OPTIONS)
24
+ profile = get_profile(load_config(), resolve_profile_name(values["profile"]))
25
+ environment_name = values["env-name"] or "INSIGHTFACTORY_ACCESS_TOKEN"
26
+ if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", environment_name):
27
+ die(f"environment variable name '{environment_name}' is invalid")
28
+ token = get_valid_token(profile)
29
+ if values["env"] or values["env-name"]:
30
+ sys.stdout.write(f"export {environment_name}={shell_quote(token)}\n")
31
+ else:
32
+ sys.stdout.write(f"{token}\n")
if_cli/config.py ADDED
@@ -0,0 +1,188 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import uuid
7
+ from typing import TypedDict
8
+
9
+ from if_cli.constants import DEFAULT_CALLBACK_PORT, ENV_PROFILE
10
+ from if_cli.http import normalize_origin, validate_audience_url
11
+ from if_cli.runtime import die
12
+
13
+ INI_UNSAFE_VALUE = re.compile(r"[\r\n]")
14
+ INI_UNSAFE_SECTION = re.compile(r"[\[\]\r\n]")
15
+
16
+
17
+ class Profile(TypedDict):
18
+ name: str
19
+ host: str
20
+ audience: str
21
+ callback_port: int
22
+ client_id: str | None
23
+ organization: str | None
24
+
25
+
26
+ def config_dir() -> str:
27
+ if "INSIGHTFACTORY_CONFIG_DIR" in os.environ:
28
+ return os.environ["INSIGHTFACTORY_CONFIG_DIR"]
29
+ return os.path.join(os.path.expanduser("~"), ".insightfactory")
30
+
31
+
32
+ def config_file() -> str:
33
+ return os.path.join(config_dir(), "config")
34
+
35
+
36
+ def cache_file() -> str:
37
+ return os.path.join(config_dir(), "token-cache.json")
38
+
39
+
40
+ def empty_section() -> dict[str, str]:
41
+ return {}
42
+
43
+
44
+ def load_config() -> dict[str, dict[str, str]]:
45
+ config: dict[str, dict[str, str]] = {}
46
+ try:
47
+ with open(config_file(), encoding="utf-8") as handle:
48
+ raw = handle.read()
49
+ except OSError:
50
+ return config
51
+
52
+ section = ""
53
+ for line in raw.split("\n"):
54
+ trimmed = line.strip()
55
+ if not trimmed or trimmed.startswith("#") or trimmed.startswith(";"):
56
+ continue
57
+
58
+ header = re.match(r"^\[(.+)\]$", trimmed)
59
+ if header:
60
+ section_name = header.group(1)
61
+ section = "" if section_name == "DEFAULT" else section_name
62
+ config.setdefault(section, empty_section())
63
+ continue
64
+
65
+ equals_index = trimmed.find("=")
66
+ if equals_index > 0:
67
+ config.setdefault(section, empty_section())[trimmed[:equals_index].strip()] = trimmed[
68
+ equals_index + 1 :
69
+ ].strip()
70
+
71
+ return config
72
+
73
+
74
+ def save_config(config: dict[str, dict[str, str]]) -> None:
75
+ lines = ["# Managed by if-cli; comments are not preserved when this file is updated.", ""]
76
+ for section, values in config.items():
77
+ profile_name = "DEFAULT" if section == "" else section
78
+ if len(values) == 0:
79
+ continue
80
+ if INI_UNSAFE_SECTION.search(section):
81
+ die(
82
+ f"cannot save profile {json.dumps(profile_name)}: its section name contains [, ], a newline, "
83
+ f"or a carriage return; fix this section in the config file at {config_file()}"
84
+ )
85
+ lines.append(f"[{profile_name}]")
86
+ for key, value in values.items():
87
+ if INI_UNSAFE_VALUE.search(key) or INI_UNSAFE_VALUE.search(value):
88
+ die(
89
+ f"cannot save profile {json.dumps(profile_name)} key {json.dumps(key)}: keys and values "
90
+ f"cannot contain newlines or carriage returns; fix this entry in the config file at {config_file()}"
91
+ )
92
+ lines.append(f"{key} = {value}")
93
+ lines.append("")
94
+ write_private_file(config_file(), "\n".join(lines))
95
+
96
+
97
+ def write_private_file(file: str, contents: str) -> None:
98
+ directory = os.path.dirname(file)
99
+ os.makedirs(directory, mode=0o700, exist_ok=True)
100
+ os.chmod(directory, 0o700)
101
+ temporary_file = f"{file}.{os.getpid()}.{uuid.uuid4()}.tmp"
102
+ descriptor: int | None = None
103
+ try:
104
+ descriptor = os.open(temporary_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
105
+ try:
106
+ os.write(descriptor, contents.encode("utf-8"))
107
+ os.fchmod(descriptor, 0o600)
108
+ os.fsync(descriptor)
109
+ finally:
110
+ os.close(descriptor)
111
+ descriptor = None
112
+ os.replace(temporary_file, file)
113
+ os.chmod(file, 0o600)
114
+ directory_descriptor: int | None = None
115
+ try:
116
+ directory_descriptor = os.open(directory, os.O_RDONLY)
117
+ os.fsync(directory_descriptor)
118
+ except OSError:
119
+ pass
120
+ finally:
121
+ if directory_descriptor is not None:
122
+ os.close(directory_descriptor)
123
+ finally:
124
+ if descriptor is not None:
125
+ try:
126
+ os.close(descriptor)
127
+ except OSError:
128
+ pass
129
+ try:
130
+ os.unlink(temporary_file)
131
+ except OSError:
132
+ pass
133
+
134
+
135
+ def meaningful(value: str | None) -> str | None:
136
+ return value if value and value.strip() else None
137
+
138
+
139
+ def parse_callback_port(value: str | None) -> int:
140
+ if value is None:
141
+ return DEFAULT_CALLBACK_PORT
142
+ try:
143
+ port = float(value)
144
+ except (TypeError, ValueError):
145
+ die(f"callback port must be an integer between 1 and 65535 (received '{value}')")
146
+ if port != int(port) or int(port) < 1 or int(port) > 65_535:
147
+ die(f"callback port must be an integer between 1 and 65535 (received '{value}')")
148
+ return int(port)
149
+
150
+
151
+ def resolve_profile_name(flag: str | None = None) -> str:
152
+ if flag is not None:
153
+ name = flag
154
+ elif ENV_PROFILE in os.environ:
155
+ name = os.environ[ENV_PROFILE]
156
+ else:
157
+ name = "DEFAULT"
158
+ if name == "" or name != name.strip() or re.search(r"[\[\]=\r\n]", name):
159
+ die(
160
+ f"invalid profile name {json.dumps(name)}: names must be non-empty, have no leading or trailing "
161
+ "whitespace, and must not contain [, ], =, newlines, or carriage returns"
162
+ )
163
+ return name
164
+
165
+
166
+ def get_profile(config: dict[str, dict[str, str]], name: str) -> Profile:
167
+ section_key = "" if name == "DEFAULT" else name
168
+ if section_key not in config:
169
+ die(f"profile '{name}' not found in {config_file()} — run: if-cli login -p {name} --host <factory-url>")
170
+ section = config[section_key]
171
+
172
+ host = section.get("host")
173
+ if not host:
174
+ die(f"profile '{name}' has no host configured")
175
+ normalized_host = normalize_origin(host, f"host for profile '{name}'")
176
+ audience_value = section.get("audience")
177
+ return {
178
+ "name": name,
179
+ "host": normalized_host,
180
+ "client_id": meaningful(section.get("client_id")),
181
+ "organization": meaningful(section.get("organization")),
182
+ "audience": (
183
+ validate_audience_url(audience_value, f"audience for profile '{name}'")
184
+ if audience_value
185
+ else f"{normalized_host}/api"
186
+ ),
187
+ "callback_port": parse_callback_port(section.get("callback_port")),
188
+ }
if_cli/constants.py ADDED
@@ -0,0 +1,5 @@
1
+ ENV_PROFILE: str = "INSIGHTFACTORY_CONFIG_PROFILE"
2
+ DEFAULT_SCOPE: str = "openid profile email offline_access"
3
+ LOOPBACK_HOST: str = "127.0.0.1"
4
+ DEFAULT_CALLBACK_PORT: int = 53682
5
+ EXPIRY_SLACK_SECONDS: int = 60
if_cli/http.py ADDED
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import socket
5
+ import urllib.error
6
+ import urllib.request
7
+ from email.message import Message
8
+ from urllib.parse import ParseResult, urljoin, urlparse
9
+
10
+ from if_cli.runtime import die
11
+
12
+ REQUEST_TIMEOUT_MS = 30_000
13
+
14
+
15
+ def _with_backslashes_as_slashes(value: str) -> str:
16
+ return value.replace("\\", "/")
17
+
18
+
19
+ def parse_url(value: str, base: str | None = None) -> ParseResult:
20
+ normalized = _with_backslashes_as_slashes(value)
21
+ if base is not None:
22
+ return urlparse(urljoin(_with_backslashes_as_slashes(base), normalized))
23
+ return urlparse(normalized)
24
+
25
+
26
+ def _host_for_url(hostname: str) -> str:
27
+ return f"[{hostname}]" if ":" in hostname else hostname
28
+
29
+
30
+ def url_origin(parsed: ParseResult) -> str:
31
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
32
+ return "null"
33
+ host = _host_for_url(parsed.hostname)
34
+ port = parsed.port
35
+ default_port = 443 if parsed.scheme == "https" else 80
36
+ if port is not None and port != default_port:
37
+ return f"{parsed.scheme}://{host}:{port}"
38
+ return f"{parsed.scheme}://{host}"
39
+
40
+
41
+ def url_href(parsed: ParseResult) -> str:
42
+ scheme = parsed.scheme.lower()
43
+ hostname = parsed.hostname
44
+ if not hostname:
45
+ raise ValueError("URL has no hostname")
46
+ host = _host_for_url(hostname)
47
+ userinfo = ""
48
+ if parsed.username:
49
+ userinfo = parsed.username
50
+ if parsed.password is not None:
51
+ userinfo += f":{parsed.password}"
52
+ userinfo += "@"
53
+ port = parsed.port
54
+ default_port = 443 if scheme == "https" else 80 if scheme == "http" else None
55
+ port_part = f":{port}" if port is not None and port != default_port else ""
56
+ path = parsed.path if parsed.path else "/"
57
+ query = f"?{parsed.query}" if parsed.query else ""
58
+ fragment = f"#{parsed.fragment}" if parsed.fragment else ""
59
+ return f"{scheme}://{userinfo}{host}{port_part}{path}{query}{fragment}"
60
+
61
+
62
+ def _is_loopback(hostname: str | None) -> bool:
63
+ if hostname is None:
64
+ return False
65
+ return hostname in {"localhost", "127.0.0.1", "::1", "[::1]"}
66
+
67
+
68
+ def parse_http_url(value: str, label: str) -> ParseResult:
69
+ try:
70
+ parsed = parse_url(value)
71
+ except ValueError:
72
+ die(f"{label} must be a valid URL")
73
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
74
+ die(f"{label} must be a valid URL")
75
+ loopback = _is_loopback(parsed.hostname)
76
+ if parsed.scheme != "https" and not (parsed.scheme == "http" and loopback):
77
+ die(f"{label} must use HTTPS (HTTP is allowed only for localhost)")
78
+ return parsed
79
+
80
+
81
+ def validate_http_url(value: str, label: str) -> str:
82
+ return url_href(parse_http_url(value, label))
83
+
84
+
85
+ def validate_audience_url(value: str, label: str) -> str:
86
+ parse_http_url(value, label)
87
+ if any(ord(character) <= 0x20 or ord(character) == 0x7F for character in value):
88
+ die(f"{label} must not contain ASCII control characters or spaces")
89
+ return value
90
+
91
+
92
+ def normalize_origin(value: str, label: str) -> str:
93
+ parsed = parse_http_url(value, label)
94
+ path = parsed.path if parsed.path else "/"
95
+ if parsed.username or parsed.password or path != "/" or parsed.query or parsed.fragment:
96
+ die(f"{label} must be a bare origin without credentials, a path, query, or fragment")
97
+ return url_origin(parsed)
98
+
99
+
100
+ class HttpResponse:
101
+ def __init__(self, status: int, headers: Message | None, body: bytes) -> None:
102
+ self.status = status
103
+ self.headers = headers
104
+ self._body = body
105
+
106
+ @property
107
+ def ok(self) -> bool:
108
+ return 200 <= self.status < 300
109
+
110
+ def text(self) -> str:
111
+ return self._body.decode("utf-8", errors="replace")
112
+
113
+ def json(self) -> object:
114
+ return json.loads(self.text())
115
+
116
+ def header(self, name: str) -> str | None:
117
+ if self.headers is None:
118
+ return None
119
+ value = self.headers.get(name)
120
+ if value is None:
121
+ return None
122
+ return str(value)
123
+
124
+
125
+ def fetch_with_timeout(
126
+ url: str,
127
+ *,
128
+ method: str = "GET",
129
+ headers: dict[str, str] | None = None,
130
+ body: str | bytes | None = None,
131
+ ) -> HttpResponse:
132
+ data: bytes | None
133
+ if body is None:
134
+ data = None
135
+ elif isinstance(body, bytes):
136
+ data = body
137
+ else:
138
+ data = body.encode("utf-8")
139
+ request = urllib.request.Request(url, data=data, headers=headers or {}, method=method)
140
+ try:
141
+ with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_MS / 1000) as response:
142
+ return HttpResponse(response.status, response.headers, response.read())
143
+ except urllib.error.HTTPError as error:
144
+ return HttpResponse(error.code, error.headers, error.read())
145
+ except urllib.error.URLError as error:
146
+ reason = error.reason
147
+ if isinstance(reason, (TimeoutError, socket.timeout)) or getattr(reason, "errno", None) == getattr(
148
+ socket, "ETIMEDOUT", None
149
+ ):
150
+ die(f"request timed out after {REQUEST_TIMEOUT_MS / 1000}s: {url}")
151
+ raise
152
+ except TimeoutError:
153
+ die(f"request timed out after {REQUEST_TIMEOUT_MS / 1000}s: {url}")