testzombie-windows 0.2.25__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,6 @@
1
+ """TestZombie Windows integration: FlaUI, RPA.Windows and Python uiautomation."""
2
+ __version__ = '0.2.25'
3
+
4
+ from .uiautomation_adapter import UiautomationHealingClient
5
+
6
+ __all__ = ['UiautomationHealingClient']
@@ -0,0 +1,152 @@
1
+ """Network contract for the additive Windows API endpoints (Phase 2.2)."""
2
+ from __future__ import annotations
3
+ import json
4
+ import os
5
+ import socket
6
+ from .config import api_base_url, api_header_names
7
+ import urllib.error
8
+ import urllib.request
9
+
10
+
11
+ class TestZombieApiError(RuntimeError):
12
+ pass
13
+
14
+
15
+ # The InitController supplies these project settings in InitZombieResult.
16
+ # Keep the allowlist explicit: no tokens, credentials or project secrets in logs.
17
+ _HEALING_FIELDS = (
18
+ ('healingLocatorFallbackEnabled', 'Locator-Fallback'),
19
+ ('healingVisualPositionEnabled', 'Visual-Position'),
20
+ ('healingWaitEnabled', 'Wait'),
21
+ ('healingClickEnabled', 'Click'),
22
+ ('healingAiEnabled', 'AI'),
23
+ ('healingStrictness', 'Strictness'),
24
+ ('healingAuditModeEnabled', 'Audit'),
25
+ )
26
+
27
+
28
+ def format_healing_configuration(config):
29
+ """Render only settings actually supplied by initZombie, never infer defaults."""
30
+ if not isinstance(config, dict):
31
+ return ''
32
+ rendered = []
33
+ for key, label in _HEALING_FIELDS:
34
+ if key not in config or config[key] is None:
35
+ continue
36
+ value = config[key]
37
+ if isinstance(value, bool):
38
+ value = 'AN' if value else 'AUS'
39
+ else:
40
+ value = str(value)
41
+ rendered.append(f'{label}={value}')
42
+ return ' | '.join(rendered)
43
+
44
+
45
+ def report_healing_configuration(api, *, emitter=print):
46
+ """Print project-controlled healing flags after a successful Init."""
47
+ summary = format_healing_configuration(getattr(api, 'healing_config', None))
48
+ if summary:
49
+ emitter('[TestZombie Windows] Healing-Konfiguration: ' + summary)
50
+
51
+
52
+ class WindowsApiClient:
53
+ def __init__(self, url=None, api_key=None, email=None, timeout=None):
54
+ try:
55
+ self.url = api_base_url(url)
56
+ self.key_header, self.mail_header = api_header_names()
57
+ except ValueError as exc:
58
+ raise TestZombieApiError(str(exc)) from exc
59
+ self.healing_config = {}
60
+ self.api_key = api_key or os.environ.get('TZ_API_KEY', '')
61
+ self.email = email or os.environ.get('TZ_API_EMAIL', '')
62
+ try:
63
+ self.timeout = float(timeout if timeout is not None else os.environ.get('TZ_API_TIMEOUT', '3'))
64
+ # findWindowsElement may execute a synchronous AI provider request.
65
+ # Its deadline must not inherit the short normal API timeout.
66
+ self.heal_timeout = float(os.environ.get('TZ_API_HEAL_TIMEOUT', '120'))
67
+ except (TypeError, ValueError) as exc:
68
+ raise TestZombieApiError('TZ_API_TIMEOUT / TZ_API_HEAL_TIMEOUT muss eine Sekundenzahl sein.') from exc
69
+ if not (0 < self.timeout <= 60):
70
+ raise TestZombieApiError('TZ_API_TIMEOUT muss groesser 0 und maximal 60 Sekunden sein.')
71
+ if not (0 < self.heal_timeout <= 300):
72
+ raise TestZombieApiError('TZ_API_HEAL_TIMEOUT muss groesser 0 und maximal 300 Sekunden sein.')
73
+ if not self.api_key:
74
+ raise TestZombieApiError('TZ_API_KEY fehlt (Projekt-API-Key konfigurieren).')
75
+
76
+ def request_timeout(self, endpoint):
77
+ return self.heal_timeout if endpoint.strip('/').lower() == 'findwindowselement' else self.timeout
78
+
79
+ def post(self, endpoint, payload):
80
+ timeout = self.request_timeout(endpoint)
81
+ url = self.url + '/api/' + endpoint.lstrip('/')
82
+ body = json.dumps(payload, ensure_ascii=False).encode('utf-8')
83
+ headers = {
84
+ 'Content-Type': 'application/json',
85
+ self.key_header: self.api_key,
86
+ self.mail_header: self.email,
87
+ }
88
+ # This marks a single Windows demo request. The server additionally
89
+ # requires a local/test Spring profile, explicit property and exact
90
+ # demo identity; a client-controlled header can never force AI alone.
91
+ if endpoint.strip('/').lower() == 'findwindowselement' and os.environ.get(
92
+ 'TZ_WINDOWS_TEST_FORCE_AI', '').strip().lower() in ('1', 'true', 'yes', 'on'):
93
+ from urllib.parse import urlsplit
94
+ if urlsplit(self.url).hostname not in ('localhost', '127.0.0.1', '::1'):
95
+ raise TestZombieApiError('Force-AI test mode requires a localhost API URL')
96
+ headers['X-TestZombie-Windows-Test-Force-AI'] = 'true'
97
+ req = urllib.request.Request(url, body, method='POST', headers=headers)
98
+ try:
99
+ with urllib.request.urlopen(req, timeout=timeout) as response:
100
+ result = json.load(response)
101
+ except urllib.error.HTTPError as exc:
102
+ # Avoid echoing response bodies potentially containing user data.
103
+ raise TestZombieApiError(f'{endpoint}: HTTP {exc.code} (API response rejected)') from exc
104
+ except (urllib.error.URLError, TimeoutError, OSError, ValueError) as exc:
105
+ # urllib can wrap a socket timeout in URLError.reason. Do not
106
+ # include raw response bodies, credentials or user input in logs.
107
+ reason = getattr(exc, 'reason', exc)
108
+ if isinstance(reason, (TimeoutError, socket.timeout)) or 'timed out' in str(reason).lower():
109
+ detail = f'timeout after {timeout:g}s'
110
+ elif isinstance(exc, ValueError):
111
+ detail = 'invalid JSON response'
112
+ else:
113
+ detail = 'network error: ' + type(exc).__name__
114
+ raise TestZombieApiError(f'{endpoint}: {detail}') from exc
115
+ if not isinstance(result, dict) or result.get('success') is False:
116
+ raise TestZombieApiError(f'{endpoint}: unsuccessful API response')
117
+ data = result.get('data')
118
+ if isinstance(data, dict) and data.get('error'):
119
+ raise TestZombieApiError(f'{endpoint}: {data["error"]}')
120
+ return data if isinstance(data, dict) else {}
121
+
122
+ def put_discovery(self, test_run_id, payload):
123
+ """PUT to the existing DiscoveryController; success is HTTP 204 (no JSON)."""
124
+ from urllib.parse import quote
125
+ if not isinstance(test_run_id, str) or not test_run_id.strip():
126
+ raise TestZombieApiError('Discovery requires a valid testRunId')
127
+ endpoint = 'test-runs/' + quote(test_run_id, safe='') + '/discovery'
128
+ url = self.url + '/api/' + endpoint
129
+ body = json.dumps(payload, ensure_ascii=False).encode('utf-8')
130
+ req = urllib.request.Request(url, body, method='PUT', headers={
131
+ 'Content-Type': 'application/json',
132
+ self.key_header: self.api_key,
133
+ self.mail_header: self.email,
134
+ })
135
+ try:
136
+ with urllib.request.urlopen(req, timeout=self.timeout) as response:
137
+ if response.status not in (200, 201, 202, 204):
138
+ raise TestZombieApiError('discovery: unexpected HTTP status')
139
+ return True
140
+ except urllib.error.HTTPError as exc:
141
+ raise TestZombieApiError(f'discovery: HTTP {exc.code}') from exc
142
+ except (urllib.error.URLError, TimeoutError, OSError, ValueError) as exc:
143
+ raise TestZombieApiError(f'discovery: network error: {type(exc).__name__}') from exc
144
+
145
+ def init_run(self, info='ROBOT_WINDOWS'):
146
+ self.healing_config = {}
147
+ data = self.post('initZombie', {'info': info})
148
+ run_id = data.get('testRunId')
149
+ if not run_id:
150
+ raise TestZombieApiError('initZombie did not return a testRunId')
151
+ self.healing_config = {key: data[key] for key, _ in _HEALING_FIELDS if key in data}
152
+ return run_id
@@ -0,0 +1,343 @@
1
+ """Opt-in, fail-closed source updates for successful Windows UIA healing.
2
+
3
+ This module never touches the Java backend, a site-packages directory, or a file
4
+ outside the explicitly allowed project root. Changes are literal-only, backed up
5
+ and written atomically. If any source location is ambiguous we do nothing.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import io
11
+ from datetime import datetime, timezone
12
+ import os
13
+ from pathlib import Path
14
+ import re
15
+ import shutil
16
+ import stat
17
+ import tempfile
18
+ import tokenize
19
+ from typing import Optional
20
+
21
+ _TRUE = {'1', 'true', 'yes', 'on'}
22
+ _ROBOT_SPLIT = re.compile(r'(\t+| {2,})')
23
+ _SKIPPED_DIRS = {'.git', '.venv', '.venv-uia', '__pycache__', 'results',
24
+ 'node_modules', 'dist', 'build', 'site-packages', '.tox'}
25
+ _MAX_FILE_BYTES = 2 * 1024 * 1024
26
+
27
+
28
+ def enabled_from_env() -> bool:
29
+ return os.environ.get('TZ_CODE_UPDATE', '').strip().lower() in _TRUE
30
+
31
+
32
+ class WindowsCodeUpdater:
33
+ """Source updater: disabled unless TZ_CODE_UPDATE explicitly set to true.
34
+
35
+ root: Only source files INSIDE this directory may be edited. By default the
36
+ current working directory is used. CI/custom runners should explicitly set
37
+ TZ_CODE_UPDATE_ROOT to the checked-out test project directory.
38
+ """
39
+
40
+ def __init__(self, *, enabled: Optional[bool] = None, root=None):
41
+ self.enabled = (enabled_from_env() if enabled is None else
42
+ enabled.strip().lower() in _TRUE if isinstance(enabled, str) else bool(enabled))
43
+ self.root = Path(root or os.environ.get('TZ_CODE_UPDATE_ROOT') or os.getcwd()).resolve()
44
+
45
+ def _allowed(self, path: Path, suffix: str) -> bool:
46
+ try:
47
+ resolved = path.resolve(strict=True)
48
+ relative = resolved.relative_to(self.root)
49
+ return (resolved.is_file() and resolved.suffix.lower() == suffix
50
+ and not any(part in _SKIPPED_DIRS for part in relative.parts)
51
+ and resolved.stat().st_size <= _MAX_FILE_BYTES)
52
+ except (OSError, ValueError):
53
+ return False
54
+
55
+ @staticmethod
56
+ def _read(path: Path):
57
+ # Respect PEP-263 encoding declarations and preserve existing newlines.
58
+ raw = path.read_bytes()
59
+ encoding, _ = tokenize.detect_encoding(io.BytesIO(raw).readline)
60
+ return raw.decode(encoding), encoding
61
+
62
+ @staticmethod
63
+ def _annotation(old: str, new: str, test_run_id, now=None):
64
+ def clean(value):
65
+ value = str(value if value is not None else '')
66
+ # Never let a malformed remote testRunId inject a second source line.
67
+ return re.sub(r'[\r\n\x00-\x1f\x7f]', ' ', value)[:550]
68
+
69
+ at = now or datetime.now(timezone.utc)
70
+ stamp = at.astimezone(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
71
+ return (f'TestZombie CodeUpdate | old={clean(old)} | new={clean(new)} '
72
+ f'| at={stamp} | testRun={clean(test_run_id)}')
73
+
74
+ @staticmethod
75
+ def _column_as_char(line, byte_column):
76
+ # Python AST stores UTF-8 byte offsets, even if source text is Unicode.
77
+ return len(line.encode('utf-8')[:byte_column].decode('utf-8'))
78
+
79
+ def _write(self, path: Path, before: str, after: str, encoding: str) -> bool:
80
+ if before == after or not self.enabled:
81
+ return False
82
+ backup = path.with_name(path.name + '.testzombie.bak')
83
+ lock = path.with_name(path.name + '.testzombie.lock')
84
+ try:
85
+ lock_fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
86
+ except OSError:
87
+ return False # Someone else is already modifying this source.
88
+ try:
89
+ os.close(lock_fd)
90
+ actual, _ = self._read(path)
91
+ if actual != before: # source changed since we evaluated the edit
92
+ return False
93
+ if backup.is_symlink():
94
+ return False
95
+ if not backup.exists():
96
+ shutil.copy2(path, backup)
97
+ mode = stat.S_IMODE(path.stat().st_mode)
98
+ fd, tmp_name = tempfile.mkstemp(prefix='.tz-update-', suffix='.tmp', dir=path.parent)
99
+ try:
100
+ with os.fdopen(fd, 'w', encoding=encoding, newline='') as f:
101
+ f.write(after)
102
+ f.flush()
103
+ os.fsync(f.fileno())
104
+ os.chmod(tmp_name, mode)
105
+ os.replace(tmp_name, path)
106
+ finally:
107
+ if os.path.exists(tmp_name):
108
+ os.unlink(tmp_name)
109
+ return True
110
+ except (OSError, UnicodeError):
111
+ return False
112
+ finally:
113
+ try:
114
+ lock.unlink()
115
+ except OSError:
116
+ pass
117
+
118
+ def update_python(self, *, frame, control_type, locator_keyword, old, new, test_run_id) -> bool:
119
+ """Update ONLY a string literal in this exact find_control call site.
120
+
121
+ A dynamic variable (or a customer wrapper that forwards variables) is
122
+ deliberately not rewritten; there is no safe automatic source location.
123
+ """
124
+ if not self.enabled or old == new or locator_keyword not in ('automation_id', 'name'):
125
+ return False
126
+ if not (isinstance(old, str) and isinstance(new, str) and new
127
+ and '\n' not in new and '\r' not in new):
128
+ return False
129
+ path = Path(frame.f_code.co_filename)
130
+ if not self._allowed(path, '.py'):
131
+ return False
132
+ try:
133
+ source, encoding = self._read(path)
134
+ tree = ast.parse(source, filename=str(path))
135
+ except (OSError, SyntaxError, UnicodeError):
136
+ return False
137
+ calls = []
138
+ for node in ast.walk(tree):
139
+ if not isinstance(node, ast.Call):
140
+ continue
141
+ func = node.func
142
+ if not isinstance(func, ast.Attribute) or func.attr != 'find_control':
143
+ continue
144
+ if not (node.lineno <= frame.f_lineno <= node.end_lineno):
145
+ continue
146
+ if not node.args or not isinstance(node.args[0], ast.Constant):
147
+ continue
148
+ if node.args[0].value not in (control_type, control_type + 'Control'):
149
+ continue
150
+ candidates = [k.value for k in node.keywords
151
+ if k.arg == locator_keyword and isinstance(k.value, ast.Constant)
152
+ and isinstance(k.value.value, str) and k.value.value == old]
153
+ if len(candidates) == 1:
154
+ calls.append(candidates[0])
155
+ if len(calls) != 1:
156
+ return False
157
+ node = calls[0]
158
+ if node.lineno != node.end_lineno:
159
+ return False # no multiline string literals / implicit concatenation
160
+ lines = source.splitlines(keepends=True)
161
+ idx = node.lineno - 1
162
+ line = lines[idx]
163
+ try:
164
+ start = self._column_as_char(line, node.col_offset)
165
+ end = self._column_as_char(line, node.end_col_offset)
166
+ except (ValueError, UnicodeError):
167
+ return False
168
+ # Explicitly verify that AST token really is just a standalone literal.
169
+ literal = line[start:end]
170
+ try:
171
+ if ast.literal_eval(literal) != old or not isinstance(ast.parse(literal, mode='eval').body, ast.Constant):
172
+ return False
173
+ except (SyntaxError, ValueError):
174
+ return False
175
+ # Preserve the number of lines (otherwise the running Python frame's
176
+ # line numbers would no longer refer to the same source call sites).
177
+ new_line = line[:start] + repr(new) + line[end:]
178
+ no_eol = new_line.rstrip('\r\n')
179
+ eol = new_line[len(no_eol):]
180
+ annotation = self._annotation(old, new, test_run_id)
181
+ new_line = no_eol + ' # ' + annotation + eol
182
+ lines[idx] = new_line
183
+ updated = ''.join(lines)
184
+ try:
185
+ ast.parse(updated, filename=str(path))
186
+ except SyntaxError:
187
+ return False
188
+ return self._write(path, source, updated, encoding)
189
+
190
+ def _suite_sources(self, source_file):
191
+ """Only files referenced by the CURRENT Robot suite may be edited.
192
+
193
+ For safety, variable-based imports (that cannot be resolved offline) are
194
+ not followed. We never glob unrelated Robot suites to find a match.
195
+ """
196
+ if not source_file:
197
+ return []
198
+ source_file = Path(source_file)
199
+ if not self._allowed(source_file, source_file.suffix.lower()):
200
+ return []
201
+ pending = [source_file.resolve()]
202
+ visited = set()
203
+ while pending and len(visited) < 100:
204
+ path = pending.pop()
205
+ if path in visited or not self._allowed(path, path.suffix.lower()):
206
+ continue
207
+ visited.add(path)
208
+ try:
209
+ source, _ = self._read(path)
210
+ except (OSError, UnicodeError):
211
+ continue
212
+ in_settings = False
213
+ for line in source.splitlines():
214
+ stripped = line.strip()
215
+ if stripped.startswith('***') and stripped.endswith('***'):
216
+ in_settings = stripped.casefold() == '*** settings ***'
217
+ continue
218
+ if not in_settings or not stripped or stripped.startswith('#'):
219
+ continue
220
+ fields = _ROBOT_SPLIT.split(stripped)
221
+ tokens = fields[::2]
222
+ if len(tokens) < 2 or tokens[0].casefold() != 'resource':
223
+ continue
224
+ entry = tokens[1].replace('${CURDIR}', str(path.parent))
225
+ if '${' in entry or '%{' in entry:
226
+ continue
227
+ child = Path(entry)
228
+ if not child.is_absolute():
229
+ child = path.parent / child
230
+ if self._allowed(child, child.suffix.lower()) and child.suffix.lower() in ('.robot', '.resource'):
231
+ pending.append(child.resolve())
232
+ return sorted(visited)
233
+
234
+ def update_robot(self, *, old, new, test_run_id, source_file=None) -> bool:
235
+ """Update unique Robot literal in current suite or imported resources.
236
+
237
+ Requires a current suite source. Unrelated files, unresolved variables,
238
+ multiple matching definitions and non-literal locators are not edited.
239
+ """
240
+ if not self.enabled or not isinstance(old, str) or not isinstance(new, str):
241
+ return False
242
+ if not old or not new or old == new or any(c in new for c in '\r\n\x00'):
243
+ return False
244
+ matches = []
245
+ for path in self._suite_sources(source_file):
246
+ try:
247
+ source, encoding = self._read(path)
248
+ except (OSError, UnicodeError):
249
+ continue
250
+ lines = source.splitlines(keepends=True)
251
+ for index, line in enumerate(lines):
252
+ stripped = line.strip()
253
+ if not stripped or stripped.startswith('#') or stripped.startswith('...'):
254
+ continue
255
+ content = line.rstrip('\r\n')
256
+ chunks = _ROBOT_SPLIT.split(content)
257
+ for position in range(0, len(chunks), 2):
258
+ token = chunks[position]
259
+ if token.strip().startswith('#'):
260
+ break
261
+ if token == old:
262
+ matches.append((path, source, encoding, lines, index, chunks, position))
263
+ if len(matches) > 1:
264
+ return False
265
+ if len(matches) != 1:
266
+ return False
267
+ path, source, encoding, lines, index, chunks, position = matches[0]
268
+ chunks[position] = new
269
+ old_line = lines[index]
270
+ eol = '\r\n' if old_line.endswith('\r\n') else ('\n' if old_line.endswith('\n') else '')
271
+ lines[index] = ''.join(chunks) + ' # ' + self._annotation(old, new, test_run_id) + eol
272
+ updated = ''.join(lines)
273
+ return self._write(path, source, updated, encoding)
274
+
275
+ def update_native_python(self, *, source_file, lineno, control_type,
276
+ locator_keyword, old, new, test_run_id) -> bool:
277
+ """Update a unique native ``auto.EditControl(AutomationId='...')`` call.
278
+
279
+ The exact constructor source location is captured at instantiation,
280
+ not guessed from the later Exists()/Click() stack. No dynamic strings,
281
+ ambiguous calls or cross-file searches are edited.
282
+ """
283
+ if not self.enabled or old == new or locator_keyword not in ('AutomationId', 'Name'):
284
+ return False
285
+ if not all(isinstance(x, str) for x in (old, new)) or not new or any(c in new for c in '\r\n\x00'):
286
+ return False
287
+ try:
288
+ path = Path(source_file)
289
+ if not self._allowed(path, '.py'):
290
+ return False
291
+ source, encoding = self._read(path)
292
+ tree = ast.parse(source, filename=str(path))
293
+ except (OSError, SyntaxError, UnicodeError, ValueError):
294
+ return False
295
+ calls = []
296
+ for call in ast.walk(tree):
297
+ if not isinstance(call, ast.Call) or not (call.lineno <= lineno <= call.end_lineno):
298
+ continue
299
+ f = call.func
300
+ if not (isinstance(f, ast.Attribute) and f.attr == control_type + 'Control'):
301
+ continue
302
+ for kw in call.keywords:
303
+ if (kw.arg == locator_keyword and isinstance(kw.value, ast.Constant)
304
+ and isinstance(kw.value.value, str) and kw.value.value == old):
305
+ calls.append(kw.value)
306
+ if len(calls) != 1:
307
+ return False
308
+ literal = calls[0]
309
+ if literal.lineno != literal.end_lineno:
310
+ return False
311
+ lines = source.splitlines(keepends=True)
312
+ idx = literal.lineno - 1
313
+ line = lines[idx]
314
+ try:
315
+ start = self._column_as_char(line, literal.col_offset)
316
+ end = self._column_as_char(line, literal.end_col_offset)
317
+ token = line[start:end]
318
+ if ast.literal_eval(token) != old:
319
+ return False
320
+ except (ValueError, SyntaxError, UnicodeError):
321
+ return False
322
+ changed = line[:start] + repr(new) + line[end:]
323
+ # Line-count stability lets the still-running interpreter update the
324
+ # next native locator in the same test without misaddressed edits.
325
+ annotation = self._annotation(old, new, test_run_id)
326
+ eol = '\r\n' if changed.endswith('\r\n') else ('\n' if changed.endswith('\n') else '')
327
+ body = changed[:-len(eol)] if eol else changed
328
+ # A test can be healed again after a previous CodeUpdate (level 1 ->
329
+ # level 2). Preserve the earlier annotation in the same source line;
330
+ # do not produce a second Python comment or erase its provenance.
331
+ if 'TestZombie CodeUpdate |' in body:
332
+ if body.count('TestZombie CodeUpdate |') >= 12 or len(body) > 12000:
333
+ return False # bounded audit trail, avoid unbounded source growth
334
+ changed_line = body + ' ; ' + annotation
335
+ else:
336
+ changed_line = body + ' # ' + annotation
337
+ lines[idx] = changed_line + eol
338
+ updated = ''.join(lines)
339
+ try:
340
+ ast.parse(updated, filename=str(path))
341
+ except SyntaxError:
342
+ return False
343
+ return self._write(path, source, updated, encoding)
@@ -0,0 +1,38 @@
1
+ """Shared API configuration for FlaUI, RPA.Windows and direct Python UIA.
2
+
3
+ Production defaults to https://testzombie.ai/api. TZ_API_URL overrides it for
4
+ local development, staging and CI. The network client appends /api/<endpoint>,
5
+ so api_base_url() intentionally returns the service root without /api.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from urllib.parse import urlsplit, urlunsplit
11
+
12
+ DEFAULT_LIVE_API_URL = "https://testzombie.ai/api"
13
+ DEFAULT_KEY_HEADER = "X-API-KEY" # Must agree with ApiKeyFilter.API_KEY_HEADER.
14
+ DEFAULT_MAIL_HEADER = "X-API-MAIL" # Must agree with ApiKeyFilter.API_MAIL_HEADER.
15
+
16
+
17
+ def api_base_url(explicit_url: str | None = None) -> str:
18
+ """Resolve base URL. Accept both .../api and ... (normalize to base)."""
19
+ candidate = (explicit_url or os.environ.get('TZ_API_URL') or DEFAULT_LIVE_API_URL).strip()
20
+ if not candidate:
21
+ raise ValueError(
22
+ 'Keine TestZombie API URL konfiguriert; pruefe TZ_API_URL oder den Live-Default.'
23
+ )
24
+ parts = urlsplit(candidate)
25
+ if parts.scheme not in ('http', 'https') or not parts.netloc or parts.query or parts.fragment:
26
+ raise ValueError('TZ_API_URL muss eine absolute http(s)-Basis-URL ohne Query/Fragment sein.')
27
+ path = parts.path.rstrip('/')
28
+ if path.endswith('/api'):
29
+ path = path[:-4]
30
+ return urlunsplit((parts.scheme, parts.netloc, path, '', '')).rstrip('/')
31
+
32
+
33
+ def api_header_names() -> tuple[str, str]:
34
+ key = os.environ.get('TZ_API_KEY_HEADER', DEFAULT_KEY_HEADER).strip()
35
+ mail = os.environ.get('TZ_API_MAIL_HEADER', DEFAULT_MAIL_HEADER).strip()
36
+ if not key or not mail:
37
+ raise ValueError('TZ_API_KEY_HEADER und TZ_API_MAIL_HEADER duerfen nicht leer sein.')
38
+ return key, mail