proxyscope 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.
Files changed (42) hide show
  1. proxyscope/__init__.py +2 -0
  2. proxyscope/app/__init__.py +0 -0
  3. proxyscope/app/config/__init__.py +0 -0
  4. proxyscope/app/config/matching.py +154 -0
  5. proxyscope/app/config/models.py +25 -0
  6. proxyscope/app/config/runtime.py +460 -0
  7. proxyscope/app/config/serialization.py +85 -0
  8. proxyscope/app/editing/__init__.py +0 -0
  9. proxyscope/app/editing/modifier.py +98 -0
  10. proxyscope/app/editing/policy.py +58 -0
  11. proxyscope/app/editing/response.py +274 -0
  12. proxyscope/app/logging/__init__.py +0 -0
  13. proxyscope/app/logging/observability.py +26 -0
  14. proxyscope/app/logging/request_response.py +179 -0
  15. proxyscope/app/logging/setup.py +11 -0
  16. proxyscope/app/main.py +90 -0
  17. proxyscope/app/runtime/__init__.py +0 -0
  18. proxyscope/app/runtime/cli.py +567 -0
  19. proxyscope/app/runtime/commands.py +419 -0
  20. proxyscope/app/runtime/input.py +151 -0
  21. proxyscope/app/runtime/journal.py +160 -0
  22. proxyscope/app/runtime/replay.py +134 -0
  23. proxyscope/app/runtime/tui/__init__.py +3 -0
  24. proxyscope/app/runtime/tui/renderer.py +531 -0
  25. proxyscope/mitm/__init__.py +0 -0
  26. proxyscope/mitm/certificates.py +149 -0
  27. proxyscope/mitm/tunnel.py +255 -0
  28. proxyscope/proxy/__init__.py +0 -0
  29. proxyscope/proxy/connect_tunnel.py +116 -0
  30. proxyscope/proxy/forwarding.py +76 -0
  31. proxyscope/proxy/http1_request_rewriter.py +167 -0
  32. proxyscope/proxy/http1_response_modifier_rewriter.py +232 -0
  33. proxyscope/proxy/http1_sniffer.py +162 -0
  34. proxyscope/proxy/http_bridge.py +35 -0
  35. proxyscope/proxy/server.py +383 -0
  36. proxyscope/proxy/tunnel_registry.py +33 -0
  37. proxyscope/proxy/types.py +8 -0
  38. proxyscope-0.1.0.dist-info/METADATA +165 -0
  39. proxyscope-0.1.0.dist-info/RECORD +42 -0
  40. proxyscope-0.1.0.dist-info/WHEEL +4 -0
  41. proxyscope-0.1.0.dist-info/entry_points.txt +3 -0
  42. proxyscope-0.1.0.dist-info/licenses/LICENSE +21 -0
proxyscope/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """tproxy package."""
2
+
File without changes
File without changes
@@ -0,0 +1,154 @@
1
+ from dataclasses import dataclass
2
+ from urllib.parse import urlsplit
3
+
4
+ from proxyscope.app.config.models import PolicyRule
5
+
6
+
7
+ def normalize_whitelist_entry(value: str) -> str:
8
+ raw_value = value.strip()
9
+ if not raw_value:
10
+ raise ValueError("Whitelist entry must not be empty.")
11
+
12
+ if "://" in raw_value:
13
+ parsed = urlsplit(raw_value)
14
+ host = parsed.hostname
15
+ elif "/" in raw_value:
16
+ parsed = urlsplit(f"http://{raw_value}")
17
+ host = parsed.hostname
18
+ else:
19
+ parsed = urlsplit(f"http://{raw_value}")
20
+ host = parsed.hostname
21
+
22
+ if not host:
23
+ raise ValueError(f"Invalid whitelist entry: {value}")
24
+ return host.lower()
25
+
26
+
27
+ def normalize_modification_url(value: str) -> str:
28
+ raw_value = value.strip()
29
+ if not raw_value:
30
+ raise ValueError("Modification URL must not be empty.")
31
+
32
+ if "://" not in raw_value:
33
+ raw_value = f"http://{raw_value}"
34
+
35
+ parsed = urlsplit(raw_value)
36
+ if not parsed.hostname:
37
+ raise ValueError(f"Invalid modification URL: {value}")
38
+
39
+ scheme = parsed.scheme.lower() or "http"
40
+ host = parsed.hostname.lower()
41
+ port = parsed.port
42
+ path = parsed.path or "/"
43
+ normalized_path = path if path.startswith("/") else f"/{path}"
44
+ netloc = f"{host}:{port}" if port is not None else host
45
+ return f"{scheme}://{netloc}{normalized_path}"
46
+
47
+
48
+ def normalize_http_method(method: str | None) -> str:
49
+ if method is None:
50
+ raise ValueError("HTTP method must not be empty.")
51
+ normalized = method.strip().upper()
52
+ if not normalized:
53
+ raise ValueError("HTTP method must not be empty.")
54
+ return normalized
55
+
56
+
57
+ def request_url_candidates(url: str) -> tuple[str, ...]:
58
+ normalized = normalize_modification_url(url)
59
+ alternate = _alternate_scheme_url(normalized)
60
+ if alternate is None:
61
+ return (normalized,)
62
+ return (normalized, alternate)
63
+
64
+
65
+ def policy_rule_matches_request(*, rule: PolicyRule, method: str, url_candidates: tuple[str, ...]) -> bool:
66
+ methods = rule.match.methods
67
+ if methods is not None and method not in methods:
68
+ return False
69
+
70
+ url_exact = rule.match.url_exact
71
+ url_prefix = rule.match.url_prefix
72
+ if url_exact is None and url_prefix is None:
73
+ return True
74
+
75
+ for candidate in url_candidates:
76
+ if url_exact is not None and candidate == url_exact:
77
+ return True
78
+ if url_prefix is not None and candidate.startswith(url_prefix):
79
+ return True
80
+ # Host-wide fallback when configured with "/"
81
+ candidate_parsed = _parse_normalized_modification_url(candidate)
82
+ if url_exact is not None:
83
+ entry = _parse_normalized_modification_url(url_exact)
84
+ if entry.host == candidate_parsed.host and entry.port == candidate_parsed.port:
85
+ if entry.path == "/" or candidate_parsed.path.startswith(entry.path):
86
+ return True
87
+ if url_prefix is not None:
88
+ entry = _parse_normalized_modification_url(url_prefix)
89
+ if entry.host == candidate_parsed.host and entry.port == candidate_parsed.port:
90
+ if entry.path == "/" or candidate_parsed.path.startswith(entry.path):
91
+ return True
92
+ return False
93
+
94
+
95
+ def rule_matches_url(rule: PolicyRule, normalized_url: str) -> bool:
96
+ target = rule.match.url_exact or rule.match.url_prefix
97
+ if target is None:
98
+ return False
99
+ return target == normalized_url
100
+
101
+
102
+ def first_rule_method(rule: PolicyRule) -> str | None:
103
+ methods = rule.match.methods
104
+ if not methods:
105
+ return None
106
+ return methods[0]
107
+
108
+
109
+ def rule_method_display(rule: PolicyRule) -> str:
110
+ method = first_rule_method(rule)
111
+ return method or "*"
112
+
113
+
114
+ def rule_url_display(rule: PolicyRule) -> str:
115
+ return rule.match.url_exact or rule.match.url_prefix or "*"
116
+
117
+
118
+ def policy_description(rule: PolicyRule) -> str:
119
+ state = "on" if rule.enabled else "off"
120
+ method = rule_method_display(rule)
121
+ target = rule_url_display(rule)
122
+ if rule.action == "open_editor":
123
+ return f"{rule.name} [{state}] open_editor {method} {target}"
124
+ if rule.action == "static_response" and rule.static_response is not None:
125
+ return (
126
+ f"{rule.name} [{state}] static_response {method} {target} "
127
+ f"-> {rule.static_response.status_code} {rule.static_response.reason}"
128
+ )
129
+ return f"{rule.name} [{state}] {rule.action} {method} {target}"
130
+
131
+
132
+ def _alternate_scheme_url(normalized_url: str) -> str | None:
133
+ parsed = urlsplit(normalized_url)
134
+ scheme = parsed.scheme.lower()
135
+ if scheme not in {"http", "https"}:
136
+ return None
137
+ alternate_scheme = "https" if scheme == "http" else "http"
138
+ return f"{alternate_scheme}://{parsed.netloc}{parsed.path or '/'}"
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class _NormalizedModUrl:
143
+ host: str
144
+ port: int | None
145
+ path: str
146
+
147
+
148
+ def _parse_normalized_modification_url(value: str) -> _NormalizedModUrl:
149
+ parsed = urlsplit(value)
150
+ return _NormalizedModUrl(
151
+ host=(parsed.hostname or "").lower(),
152
+ port=parsed.port,
153
+ path=parsed.path or "/",
154
+ )
@@ -0,0 +1,25 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class RequestMatchRule:
6
+ methods: tuple[str, ...] | None = None
7
+ url_exact: str | None = None
8
+ url_prefix: str | None = None
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class StaticResponseTemplate:
13
+ status_code: int
14
+ reason: str
15
+ headers: dict[str, str]
16
+ body: bytes
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class PolicyRule:
21
+ name: str
22
+ enabled: bool
23
+ action: str # "open_editor" | "static_response"
24
+ match: RequestMatchRule
25
+ static_response: StaticResponseTemplate | None = None
@@ -0,0 +1,460 @@
1
+ from collections.abc import Iterable
2
+ import json
3
+ import logging
4
+ from pathlib import Path
5
+ from threading import RLock
6
+
7
+ from proxyscope.app.config.matching import (
8
+ first_rule_method,
9
+ normalize_http_method,
10
+ normalize_modification_url,
11
+ normalize_whitelist_entry,
12
+ policy_description,
13
+ policy_rule_matches_request,
14
+ request_url_candidates,
15
+ rule_matches_url,
16
+ rule_method_display,
17
+ rule_url_display,
18
+ )
19
+ from proxyscope.app.config.models import PolicyRule, RequestMatchRule, StaticResponseTemplate
20
+ from proxyscope.app.config.serialization import (
21
+ parse_policy_rule as parse_policy_rule_payload,
22
+ serialize_policy_rule as serialize_policy_rule_payload,
23
+ )
24
+
25
+
26
+ class RuntimeConfig:
27
+ """
28
+ Runtime configuration shared by logging, policy matching, and UI.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ *,
34
+ log_level: int = logging.INFO,
35
+ log_whitelist: Iterable[str] | None = None,
36
+ cache_invalidation_enabled: bool = True,
37
+ config_path: str | Path | None = None,
38
+ policy_rules: Iterable[PolicyRule] | None = None,
39
+ ) -> None:
40
+ self._lock = RLock()
41
+ self._log_level = log_level
42
+ self._log_whitelist: set[str] = set()
43
+ self._cache_invalidation_enabled = cache_invalidation_enabled
44
+ self._config_path = Path(config_path) if config_path is not None else None
45
+ self._policy_rules: list[PolicyRule] = list(policy_rules or [])
46
+
47
+ if log_whitelist is not None:
48
+ for entry in log_whitelist:
49
+ self._log_whitelist.add(normalize_whitelist_entry(entry))
50
+
51
+ @property
52
+ def config_path(self) -> Path | None:
53
+ with self._lock:
54
+ return self._config_path
55
+
56
+ @property
57
+ def log_level(self) -> int:
58
+ with self._lock:
59
+ return self._log_level
60
+
61
+ def log_level_name(self) -> str:
62
+ with self._lock:
63
+ return logging.getLevelName(self._log_level)
64
+
65
+ def set_log_level(self, value: str | int) -> int:
66
+ with self._lock:
67
+ if isinstance(value, int):
68
+ new_level = value
69
+ else:
70
+ normalized = value.strip().upper()
71
+ if not normalized:
72
+ raise ValueError("Log level must not be empty.")
73
+ new_level = getattr(logging, normalized, None)
74
+ if not isinstance(new_level, int):
75
+ raise ValueError(f"Unsupported log level: {value}")
76
+ self._log_level = new_level
77
+ self._persist_if_configured()
78
+ return new_level
79
+
80
+ def whitelist_entries(self) -> tuple[str, ...]:
81
+ with self._lock:
82
+ return tuple(sorted(self._log_whitelist))
83
+
84
+ def add_whitelist_entry(self, value: str) -> str:
85
+ normalized_host = normalize_whitelist_entry(value)
86
+ with self._lock:
87
+ self._log_whitelist.add(normalized_host)
88
+ self._persist_if_configured()
89
+ return normalized_host
90
+
91
+ def remove_whitelist_entry(self, value: str) -> bool:
92
+ normalized_host = normalize_whitelist_entry(value)
93
+ removed = False
94
+ with self._lock:
95
+ if normalized_host in self._log_whitelist:
96
+ self._log_whitelist.remove(normalized_host)
97
+ removed = True
98
+ if removed:
99
+ self._persist_if_configured()
100
+ return removed
101
+
102
+ def clear_whitelist(self) -> None:
103
+ with self._lock:
104
+ self._log_whitelist.clear()
105
+ self._persist_if_configured()
106
+
107
+ def should_log_for_host(self, host: str | None) -> bool:
108
+ with self._lock:
109
+ if not self._log_whitelist:
110
+ return True
111
+ if host is None:
112
+ return False
113
+ normalized_host = normalize_whitelist_entry(host)
114
+ return normalized_host in self._log_whitelist
115
+
116
+ @property
117
+ def cache_invalidation_enabled(self) -> bool:
118
+ with self._lock:
119
+ return self._cache_invalidation_enabled
120
+
121
+ def set_cache_invalidation_enabled(self, enabled: bool) -> bool:
122
+ with self._lock:
123
+ self._cache_invalidation_enabled = enabled
124
+ self._persist_if_configured()
125
+ return enabled
126
+
127
+ def toggle_cache_invalidation(self) -> bool:
128
+ with self._lock:
129
+ self._cache_invalidation_enabled = not self._cache_invalidation_enabled
130
+ enabled = self._cache_invalidation_enabled
131
+ self._persist_if_configured()
132
+ return enabled
133
+
134
+ def policy_rules(self) -> tuple[PolicyRule, ...]:
135
+ with self._lock:
136
+ return tuple(self._policy_rules)
137
+
138
+ def set_policy_rules(self, rules: Iterable[PolicyRule]) -> None:
139
+ with self._lock:
140
+ self._policy_rules = list(rules)
141
+ self._persist_if_configured()
142
+
143
+ def add_policy_rule(self, rule: PolicyRule) -> None:
144
+ with self._lock:
145
+ self._policy_rules.append(rule)
146
+ self._persist_if_configured()
147
+
148
+ def clear_policy_rules(self) -> None:
149
+ with self._lock:
150
+ self._policy_rules.clear()
151
+ self._persist_if_configured()
152
+
153
+ def policy_descriptions(self) -> tuple[str, ...]:
154
+ with self._lock:
155
+ return tuple(policy_description(rule) for rule in self._policy_rules)
156
+
157
+ def remove_policy_rule(self, name: str) -> bool:
158
+ normalized = name.strip()
159
+ if not normalized:
160
+ raise ValueError("Policy name must not be empty.")
161
+ removed = False
162
+ with self._lock:
163
+ kept: list[PolicyRule] = []
164
+ for rule in self._policy_rules:
165
+ if rule.name == normalized:
166
+ removed = True
167
+ continue
168
+ kept.append(rule)
169
+ self._policy_rules = kept
170
+ if removed:
171
+ self._persist_if_configured()
172
+ return removed
173
+
174
+ def get_policy_rule(self, name: str) -> PolicyRule | None:
175
+ normalized = name.strip()
176
+ if not normalized:
177
+ raise ValueError("Policy name must not be empty.")
178
+ with self._lock:
179
+ for rule in self._policy_rules:
180
+ if rule.name == normalized:
181
+ return rule
182
+ return None
183
+
184
+ def replace_policy_rule(self, name: str, replacement: PolicyRule) -> bool:
185
+ normalized = name.strip()
186
+ if not normalized:
187
+ raise ValueError("Policy name must not be empty.")
188
+ replaced = False
189
+ with self._lock:
190
+ updated: list[PolicyRule] = []
191
+ for rule in self._policy_rules:
192
+ if not replaced and rule.name == normalized:
193
+ updated.append(replacement)
194
+ replaced = True
195
+ continue
196
+ updated.append(rule)
197
+ self._policy_rules = updated
198
+ if replaced:
199
+ self._persist_if_configured()
200
+ return replaced
201
+
202
+ def set_policy_rule_enabled(self, name: str, *, enabled: bool) -> bool:
203
+ normalized = name.strip()
204
+ if not normalized:
205
+ raise ValueError("Policy name must not be empty.")
206
+ changed = False
207
+ with self._lock:
208
+ updated: list[PolicyRule] = []
209
+ for rule in self._policy_rules:
210
+ if rule.name != normalized:
211
+ updated.append(rule)
212
+ continue
213
+ updated.append(
214
+ PolicyRule(
215
+ name=rule.name,
216
+ enabled=enabled,
217
+ action=rule.action,
218
+ match=rule.match,
219
+ static_response=rule.static_response,
220
+ )
221
+ )
222
+ changed = True
223
+ self._policy_rules = updated
224
+ if changed:
225
+ self._persist_if_configured()
226
+ return changed
227
+
228
+ def add_static_response_rule(
229
+ self,
230
+ *,
231
+ url: str,
232
+ status_code: int = 200,
233
+ reason: str = "OK",
234
+ headers: dict[str, str] | None = None,
235
+ body: bytes = b"",
236
+ method: str | None = None,
237
+ url_prefix: bool = False,
238
+ name: str | None = None,
239
+ ) -> str:
240
+ normalized_url = normalize_modification_url(url)
241
+ normalized_method = normalize_http_method(method) if method is not None else None
242
+ rule_name = name or f"static-response-{len(self._policy_rules) + 1}"
243
+ match = RequestMatchRule(
244
+ methods=(normalized_method,) if normalized_method is not None else None,
245
+ url_prefix=normalized_url if url_prefix else None,
246
+ url_exact=None if url_prefix else normalized_url,
247
+ )
248
+ self.add_policy_rule(
249
+ PolicyRule(
250
+ name=rule_name,
251
+ enabled=True,
252
+ action="static_response",
253
+ match=match,
254
+ static_response=StaticResponseTemplate(
255
+ status_code=status_code,
256
+ reason=reason,
257
+ headers=dict(headers or {}),
258
+ body=body,
259
+ ),
260
+ )
261
+ )
262
+ return rule_name
263
+
264
+ def modification_whitelist_entries(self) -> tuple[str, ...]:
265
+ with self._lock:
266
+ entries = [rule for rule in self._policy_rules if rule.action == "open_editor" and rule.enabled]
267
+ return tuple(f"{rule_method_display(rule)} {rule_url_display(rule)}" for rule in entries)
268
+
269
+ def open_editor_policy_entries(self) -> tuple[str, ...]:
270
+ return self.modification_whitelist_entries()
271
+
272
+ def add_open_editor_policy(self, value: str, *, method: str = "GET") -> str:
273
+ return self.add_modification_whitelist_entry(value, method=method)
274
+
275
+ def remove_open_editor_policy(self, value: str, *, method: str | None = None) -> bool:
276
+ return self.remove_modification_whitelist_entry(value, method=method)
277
+
278
+ def clear_open_editor_policies(self) -> None:
279
+ self.clear_modification_whitelist()
280
+
281
+ def add_modification_whitelist_entry(self, value: str, *, method: str = "GET") -> str:
282
+ normalized_url = normalize_modification_url(value)
283
+ normalized_method = normalize_http_method(method)
284
+ name = f"open-editor-{len(self._policy_rules) + 1}"
285
+ self.add_policy_rule(
286
+ PolicyRule(
287
+ name=name,
288
+ enabled=True,
289
+ action="open_editor",
290
+ match=RequestMatchRule(methods=(normalized_method,), url_exact=normalized_url),
291
+ )
292
+ )
293
+ return f"{normalized_method} {normalized_url}"
294
+
295
+ def remove_modification_whitelist_entry(self, value: str, *, method: str | None = None) -> bool:
296
+ normalized_url = normalize_modification_url(value)
297
+ normalized_method = normalize_http_method(method) if method is not None else None
298
+ removed = False
299
+ with self._lock:
300
+ kept: list[PolicyRule] = []
301
+ for rule in self._policy_rules:
302
+ if rule.action != "open_editor":
303
+ kept.append(rule)
304
+ continue
305
+ if not rule_matches_url(rule, normalized_url):
306
+ kept.append(rule)
307
+ continue
308
+ rule_method = first_rule_method(rule)
309
+ if normalized_method is not None and rule_method != normalized_method:
310
+ kept.append(rule)
311
+ continue
312
+ removed = True
313
+ self._policy_rules = kept
314
+ if removed:
315
+ self._persist_if_configured()
316
+ return removed
317
+
318
+ def clear_modification_whitelist(self) -> None:
319
+ with self._lock:
320
+ self._policy_rules = [rule for rule in self._policy_rules if rule.action != "open_editor"]
321
+ self._persist_if_configured()
322
+
323
+ def should_modify_response_for_request(self, *, method: str, url: str) -> bool:
324
+ normalized_method = normalize_http_method(method)
325
+ candidates = request_url_candidates(url)
326
+ with self._lock:
327
+ for rule in self._policy_rules:
328
+ if not rule.enabled or rule.action != "open_editor":
329
+ continue
330
+ if policy_rule_matches_request(rule=rule, method=normalized_method, url_candidates=candidates):
331
+ return True
332
+ return False
333
+
334
+ def get_static_response_template_for_request(
335
+ self,
336
+ *,
337
+ method: str,
338
+ url: str,
339
+ ) -> StaticResponseTemplate | None:
340
+ normalized_method = normalize_http_method(method)
341
+ candidates = request_url_candidates(url)
342
+ with self._lock:
343
+ for rule in self._policy_rules:
344
+ if not rule.enabled or rule.action != "static_response":
345
+ continue
346
+ if policy_rule_matches_request(rule=rule, method=normalized_method, url_candidates=candidates):
347
+ return rule.static_response
348
+ return None
349
+
350
+ def attach_config_path(self, path: str | Path) -> None:
351
+ with self._lock:
352
+ self._config_path = Path(path)
353
+
354
+ def save_to_path(self, path: str | Path) -> Path:
355
+ resolved = Path(path)
356
+ with self._lock:
357
+ self._config_path = resolved
358
+ self.save()
359
+ return resolved
360
+
361
+ def reload_from_attached_file(self) -> bool:
362
+ with self._lock:
363
+ path = self._config_path
364
+ if path is None:
365
+ return False
366
+ reloaded = RuntimeConfig.load_from_file(path)
367
+ with self._lock:
368
+ self._log_level = reloaded.log_level
369
+ self._log_whitelist = set(reloaded.whitelist_entries())
370
+ self._cache_invalidation_enabled = reloaded.cache_invalidation_enabled
371
+ self._policy_rules = list(reloaded.policy_rules())
372
+ return True
373
+
374
+ def save(self) -> None:
375
+ with self._lock:
376
+ path = self._config_path
377
+ payload = self._to_dict_locked()
378
+ if path is None:
379
+ return
380
+ path.parent.mkdir(parents=True, exist_ok=True)
381
+ path.write_text(json.dumps(payload, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
382
+
383
+ def _persist_if_configured(self) -> None:
384
+ with self._lock:
385
+ has_path = self._config_path is not None
386
+ if has_path:
387
+ self.save()
388
+
389
+ def _to_dict_locked(self) -> dict:
390
+ return {
391
+ "log_level": logging.getLevelName(self._log_level),
392
+ "log_whitelist": sorted(self._log_whitelist),
393
+ "cache_invalidation_enabled": self._cache_invalidation_enabled,
394
+ "policies": [serialize_policy_rule_payload(rule) for rule in self._policy_rules],
395
+ }
396
+
397
+ @classmethod
398
+ def load_from_file(cls, path: str | Path) -> "RuntimeConfig":
399
+ resolved_path = Path(path)
400
+ if not resolved_path.exists():
401
+ return cls(config_path=resolved_path)
402
+
403
+ raw = json.loads(resolved_path.read_text(encoding="utf-8"))
404
+ level_raw = str(raw.get("log_level", "INFO")).strip().upper()
405
+ level_value = getattr(logging, level_raw, logging.INFO)
406
+ whitelist_raw = raw.get("log_whitelist", [])
407
+ cache_enabled = bool(raw.get("cache_invalidation_enabled", False))
408
+
409
+ rules: list[PolicyRule] = []
410
+ for item in raw.get("policies", []):
411
+ parsed = parse_policy_rule_payload(item)
412
+ if parsed is not None:
413
+ rules.append(parsed)
414
+
415
+ return cls(
416
+ log_level=level_value,
417
+ log_whitelist=whitelist_raw,
418
+ cache_invalidation_enabled=cache_enabled,
419
+ config_path=resolved_path,
420
+ policy_rules=rules,
421
+ )
422
+
423
+
424
+ _runtime_config = RuntimeConfig()
425
+ _runtime_config_lock = RLock()
426
+
427
+
428
+ def set_runtime_config(config: RuntimeConfig) -> None:
429
+ global _runtime_config
430
+ with _runtime_config_lock:
431
+ _runtime_config = config
432
+
433
+
434
+ def get_runtime_config() -> RuntimeConfig:
435
+ with _runtime_config_lock:
436
+ return _runtime_config
437
+
438
+
439
+ def should_log_for_host(host: str | None) -> bool:
440
+ return get_runtime_config().should_log_for_host(host)
441
+
442
+
443
+ def is_cache_invalidation_enabled() -> bool:
444
+ return get_runtime_config().cache_invalidation_enabled
445
+
446
+
447
+ def should_modify_response_for_request(*, method: str, url: str) -> bool:
448
+ return get_runtime_config().should_modify_response_for_request(method=method, url=url)
449
+
450
+
451
+ def get_static_response_template_for_request(*, method: str, url: str) -> StaticResponseTemplate | None:
452
+ return get_runtime_config().get_static_response_template_for_request(method=method, url=url)
453
+
454
+
455
+ def serialize_policy_rule(rule: PolicyRule) -> dict:
456
+ return serialize_policy_rule_payload(rule)
457
+
458
+
459
+ def parse_policy_rule(data: object) -> PolicyRule | None:
460
+ return parse_policy_rule_payload(data)