ankole-framework 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.
Files changed (64) hide show
  1. ankole/__init__.py +88 -0
  2. ankole/driver/__init__.py +42 -0
  3. ankole/driver/a11y.py +228 -0
  4. ankole/driver/api_driver.py +288 -0
  5. ankole/driver/api_mock.py +218 -0
  6. ankole/driver/appmanager.py +297 -0
  7. ankole/driver/base.py +140 -0
  8. ankole/driver/cli_driver.py +180 -0
  9. ankole/driver/config_validator.py +182 -0
  10. ankole/driver/console_runner.py +377 -0
  11. ankole/driver/db_driver.py +162 -0
  12. ankole/driver/evidence.py +359 -0
  13. ankole/driver/grafana_push.py +198 -0
  14. ankole/driver/health_check.py +213 -0
  15. ankole/driver/kiwi_tcms.py +817 -0
  16. ankole/driver/log_collector.py +488 -0
  17. ankole/driver/loki_collector.py +333 -0
  18. ankole/driver/remote_trigger.py +435 -0
  19. ankole/driver/smoke_gate.py +97 -0
  20. ankole/driver/ui_driver.py +1199 -0
  21. ankole/driver/visual.py +197 -0
  22. ankole/driver/web_driver.py +297 -0
  23. ankole/driver/window_monitor.py +232 -0
  24. ankole/driver/zap_scanner.py +205 -0
  25. ankole/flows/__init__.py +10 -0
  26. ankole/flows/base.py +243 -0
  27. ankole/flows/workspace/__init__.py +1 -0
  28. ankole/flows/workspace/member_management.py +96 -0
  29. ankole/flows/workspace/project_approval.py +81 -0
  30. ankole/pages/__init__.py +11 -0
  31. ankole/pages/base_page.py +135 -0
  32. ankole/pages/desktop/__init__.py +0 -0
  33. ankole/pages/web/__init__.py +5 -0
  34. ankole/pages/web/base_web_page.py +69 -0
  35. ankole/pages/web/dashboard_page.py +44 -0
  36. ankole/pages/web/login_page.py +48 -0
  37. ankole/pages/web/member_management_page.py +95 -0
  38. ankole/pages/web/project_approval_page.py +112 -0
  39. ankole/pages/web/role_management_page.py +42 -0
  40. ankole/plugin/__init__.py +39 -0
  41. ankole/plugin/config.py +223 -0
  42. ankole/plugin/fixtures.py +173 -0
  43. ankole/plugin/flaky_tracker.py +129 -0
  44. ankole/plugin/hooks.py +371 -0
  45. ankole/plugin/kiwi_hooks.py +158 -0
  46. ankole/plugin/metrics.py +49 -0
  47. ankole/steps/__init__.py +1 -0
  48. ankole/steps/workspace/__init__.py +1 -0
  49. ankole/steps/workspace/login.py +26 -0
  50. ankole/steps/workspace/member_management.py +107 -0
  51. ankole/steps/workspace/project_approval.py +68 -0
  52. ankole/testing/__init__.py +19 -0
  53. ankole/testing/conftest_factory.py +198 -0
  54. ankole/testing/conftest_hooks.py +95 -0
  55. ankole/testing/conftest_utils.py +64 -0
  56. ankole/testing/data_factory.py +262 -0
  57. ankole/testing/parallel.py +63 -0
  58. ankole/testing/security.py +254 -0
  59. ankole_framework-1.0.0.dist-info/METADATA +259 -0
  60. ankole_framework-1.0.0.dist-info/RECORD +64 -0
  61. ankole_framework-1.0.0.dist-info/WHEEL +5 -0
  62. ankole_framework-1.0.0.dist-info/entry_points.txt +2 -0
  63. ankole_framework-1.0.0.dist-info/licenses/LICENSE +21 -0
  64. ankole_framework-1.0.0.dist-info/top_level.txt +1 -0
ankole/__init__.py ADDED
@@ -0,0 +1,88 @@
1
+ """
2
+ Ankole Framework - Multi-driver E2E test framework.
3
+
4
+ Supports Playwright (web), pywinauto (desktop), httpx (API), and subprocess (CLI)
5
+ with full observability: Grafana, Prometheus, Loki, Allure, Kiwi TCMS.
6
+
7
+ Install:
8
+ pip install ankole-framework[all]
9
+
10
+ Usage:
11
+ from ankole import WebDriver, APIDriver, UIDriver, ConsoleRunner, Evidence
12
+ """
13
+
14
+ import importlib
15
+
16
+ __version__ = "2.0.0"
17
+
18
+ # Eagerly import cross-platform modules only
19
+ from ankole.driver.evidence import Evidence, StepTracker, tracked_step
20
+ from ankole.driver.console_runner import ConsoleRunner, CommandResult, resolve_platform_config
21
+ from ankole.driver.log_collector import LogCollector, LogMonitor
22
+ from ankole.driver.kiwi_tcms import KiwiReporter
23
+ from ankole.driver.grafana_push import MetricsPusher
24
+ from ankole.driver.health_check import HealthChecker, HealthCheckResult, HealthCheckReport
25
+ from ankole.driver.smoke_gate import SmokeGate
26
+
27
+ # Lazy import for platform-specific / optional-dep modules
28
+ _LAZY_IMPORTS = {
29
+ # Desktop (pywinauto)
30
+ "UIDriver": "ankole.driver.ui_driver",
31
+ "WindowMonitor": "ankole.driver.window_monitor",
32
+ "UIAppManager": "ankole.driver.appmanager",
33
+ # Web (Playwright)
34
+ "WebDriver": "ankole.driver.web_driver",
35
+ # API (httpx)
36
+ "APIDriver": "ankole.driver.api_driver",
37
+ # Infrastructure
38
+ "RemoteTrigger": "ankole.driver.remote_trigger",
39
+ "RemoteResult": "ankole.driver.remote_trigger",
40
+ "RemoteAgentPool": "ankole.driver.remote_trigger",
41
+ "LokiLogCollector": "ankole.driver.loki_collector",
42
+ "DriverProtocol": "ankole.driver.base",
43
+ "WebDriverProtocol": "ankole.driver.base",
44
+ "APIDriverProtocol": "ankole.driver.base",
45
+ "CLIDriver": "ankole.driver.cli_driver",
46
+ "ConfigValidator": "ankole.driver.config_validator",
47
+ "ConfigValidationError": "ankole.driver.config_validator",
48
+ "BasePage": "ankole.pages.base_page",
49
+ }
50
+
51
+
52
+ def __getattr__(name):
53
+ if name in _LAZY_IMPORTS:
54
+ module = importlib.import_module(_LAZY_IMPORTS[name])
55
+ return getattr(module, name)
56
+ raise AttributeError(f"module 'ankole' has no attribute {name!r}")
57
+
58
+
59
+ __all__ = [
60
+ # Core driver infrastructure
61
+ "UIDriver",
62
+ "WebDriver",
63
+ "APIDriver",
64
+ "DriverProtocol",
65
+ "WebDriverProtocol",
66
+ "APIDriverProtocol",
67
+ "ConsoleRunner",
68
+ "CommandResult",
69
+ "resolve_platform_config",
70
+ "Evidence",
71
+ "StepTracker",
72
+ "tracked_step",
73
+ "LogCollector",
74
+ "LogMonitor",
75
+ "LokiLogCollector",
76
+ "KiwiReporter",
77
+ "MetricsPusher",
78
+ "HealthChecker",
79
+ "HealthCheckResult",
80
+ "HealthCheckReport",
81
+ "SmokeGate",
82
+ "WindowMonitor",
83
+ "RemoteTrigger",
84
+ "RemoteResult",
85
+ "RemoteAgentPool",
86
+ # Generic Page Object base
87
+ "BasePage",
88
+ ]
@@ -0,0 +1,42 @@
1
+ """Driver modules — infrastructure, evidence, integrations.
2
+
3
+ Re-exports key classes for convenience::
4
+
5
+ from ankole.driver import UIDriver, Evidence, tracked_step
6
+ """
7
+
8
+ from ankole.driver.appmanager import UIAppManager
9
+ from ankole.driver.cli_driver import CLIDriver
10
+ from ankole.driver.config_validator import ConfigValidationError, ConfigValidator
11
+ from ankole.driver.console_runner import CommandResult, ConsoleRunner, resolve_platform_config
12
+ from ankole.driver.evidence import Evidence, StepTracker, tracked_step
13
+ from ankole.driver.grafana_push import MetricsPusher
14
+ from ankole.driver.health_check import HealthChecker, HealthCheckReport, HealthCheckResult
15
+ from ankole.driver.kiwi_tcms import KiwiReporter
16
+ from ankole.driver.log_collector import LogCollector, LogMonitor
17
+ from ankole.driver.smoke_gate import SmokeGate
18
+ from ankole.driver.zap_scanner import ZAPAlert, ZAPScanner, ZAPScanReport
19
+
20
+ __all__ = [
21
+ "Evidence",
22
+ "StepTracker",
23
+ "tracked_step",
24
+ "ConsoleRunner",
25
+ "CommandResult",
26
+ "resolve_platform_config",
27
+ "LogCollector",
28
+ "LogMonitor",
29
+ "KiwiReporter",
30
+ "MetricsPusher",
31
+ "HealthChecker",
32
+ "HealthCheckResult",
33
+ "HealthCheckReport",
34
+ "SmokeGate",
35
+ "UIAppManager",
36
+ "CLIDriver",
37
+ "ConfigValidator",
38
+ "ConfigValidationError",
39
+ "ZAPScanner",
40
+ "ZAPAlert",
41
+ "ZAPScanReport",
42
+ ]
ankole/driver/a11y.py ADDED
@@ -0,0 +1,228 @@
1
+ """Accessibility testing via axe-core injection.
2
+
3
+ Injects axe-core into Playwright pages and runs WCAG compliance scans::
4
+
5
+ scanner = A11yScanner()
6
+ report = scanner.scan(web_driver, tags=["wcag2a", "wcag2aa"])
7
+ report.assert_no_violations(impact=["critical", "serious"])
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ AXE_CDN_URL = "https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.9.1/axe.min.js"
20
+
21
+
22
+ @dataclass
23
+ class A11yViolation:
24
+ """Single accessibility violation."""
25
+
26
+ rule_id: str
27
+ impact: str # "minor", "moderate", "serious", "critical"
28
+ description: str
29
+ help_url: str
30
+ nodes: list[dict[str, Any]] = field(default_factory=list)
31
+
32
+ @property
33
+ def target_selectors(self) -> list[str]:
34
+ """CSS selectors of affected elements."""
35
+ targets = []
36
+ for node in self.nodes:
37
+ for target in node.get("target", []):
38
+ if isinstance(target, list):
39
+ targets.extend(target)
40
+ else:
41
+ targets.append(target)
42
+ return targets
43
+
44
+
45
+ @dataclass
46
+ class A11yReport:
47
+ """Accessibility scan results."""
48
+
49
+ url: str
50
+ violations: list[A11yViolation] = field(default_factory=list)
51
+ passes: int = 0
52
+ inapplicable: int = 0
53
+ incomplete: int = 0
54
+
55
+ @property
56
+ def violation_count(self) -> int:
57
+ """Total number of violations."""
58
+ return len(self.violations)
59
+
60
+ def violations_by_impact(self, impact: list[str] | None = None) -> list[A11yViolation]:
61
+ """Filter violations by impact level."""
62
+ if not impact:
63
+ return self.violations
64
+ return [v for v in self.violations if v.impact in impact]
65
+
66
+ def assert_no_violations(
67
+ self, impact: list[str] | None = None, msg: str | None = None,
68
+ ) -> "A11yReport":
69
+ """Assert no violations at specified impact levels.
70
+
71
+ Args:
72
+ impact: Impact levels to check (e.g., ["critical", "serious"]).
73
+ If None, checks all violations.
74
+ msg: Custom error message.
75
+
76
+ Returns:
77
+ self for chaining.
78
+ """
79
+ filtered = self.violations_by_impact(impact)
80
+ if filtered:
81
+ details = "\n".join(
82
+ f" - [{v.impact.upper()}] {v.rule_id}: {v.description} "
83
+ f"({len(v.nodes)} elements)"
84
+ for v in filtered
85
+ )
86
+ error = msg or (
87
+ f"Found {len(filtered)} accessibility violation(s) "
88
+ f"at {self.url}:\n{details}"
89
+ )
90
+ raise AssertionError(error)
91
+ return self
92
+
93
+ def attach_to_allure(self) -> "A11yReport":
94
+ """Attach report to Allure as JSON."""
95
+ try:
96
+ import allure
97
+
98
+ report_data = {
99
+ "url": self.url,
100
+ "violations": [
101
+ {
102
+ "rule_id": v.rule_id,
103
+ "impact": v.impact,
104
+ "description": v.description,
105
+ "help_url": v.help_url,
106
+ "elements": v.target_selectors,
107
+ }
108
+ for v in self.violations
109
+ ],
110
+ "summary": {
111
+ "violations": self.violation_count,
112
+ "passes": self.passes,
113
+ "inapplicable": self.inapplicable,
114
+ "incomplete": self.incomplete,
115
+ },
116
+ }
117
+ allure.attach(
118
+ json.dumps(report_data, indent=2),
119
+ name="Accessibility Report",
120
+ attachment_type=allure.attachment_type.JSON,
121
+ )
122
+ except ImportError:
123
+ pass
124
+ return self
125
+
126
+ def summary(self) -> str:
127
+ """Human-readable summary."""
128
+ return (
129
+ f"A11y Report for {self.url}: "
130
+ f"{self.violation_count} violations, {self.passes} passes, "
131
+ f"{self.inapplicable} inapplicable, {self.incomplete} incomplete"
132
+ )
133
+
134
+
135
+ class A11yScanner:
136
+ """Accessibility scanner using axe-core via Playwright page.evaluate().
137
+
138
+ Injects axe-core from CDN into the page and runs WCAG compliance scans.
139
+ """
140
+
141
+ def __init__(
142
+ self,
143
+ axe_cdn_url: str = AXE_CDN_URL,
144
+ default_tags: list[str] | None = None,
145
+ disabled_rules: list[str] | None = None,
146
+ ):
147
+ self.axe_cdn_url = axe_cdn_url
148
+ self.default_tags = default_tags or ["wcag2a", "wcag2aa"]
149
+ self.disabled_rules = disabled_rules or []
150
+
151
+ def _inject_axe(self, page: Any) -> None:
152
+ """Inject axe-core library into the page."""
153
+ is_loaded = page.evaluate("typeof window.axe !== 'undefined'")
154
+ if not is_loaded:
155
+ page.evaluate(
156
+ """async (url) => {
157
+ const script = document.createElement('script');
158
+ script.src = url;
159
+ document.head.appendChild(script);
160
+ await new Promise((resolve, reject) => {
161
+ script.onload = resolve;
162
+ script.onerror = reject;
163
+ });
164
+ }""",
165
+ self.axe_cdn_url,
166
+ )
167
+ logger.debug("axe-core injected into page")
168
+
169
+ def scan(
170
+ self,
171
+ driver: Any,
172
+ selector: str | None = None,
173
+ tags: list[str] | None = None,
174
+ disabled_rules: list[str] | None = None,
175
+ ) -> A11yReport:
176
+ """Run accessibility scan on the current page.
177
+
178
+ Args:
179
+ driver: WebDriver instance with a `page` property.
180
+ selector: CSS selector to scope the scan (default: entire page).
181
+ tags: WCAG tags to test (e.g., ["wcag2a", "wcag2aa"]).
182
+ disabled_rules: Rule IDs to skip.
183
+
184
+ Returns:
185
+ A11yReport with scan results.
186
+ """
187
+ page = driver.page
188
+ self._inject_axe(page)
189
+
190
+ effective_tags = tags or self.default_tags
191
+ effective_disabled = disabled_rules or self.disabled_rules
192
+
193
+ # Build axe.run options
194
+ options = {"runOnly": {"type": "tag", "values": effective_tags}}
195
+ if effective_disabled:
196
+ options["rules"] = {rule: {"enabled": False} for rule in effective_disabled}
197
+
198
+ context = f"'{selector}'" if selector else "document"
199
+
200
+ results = page.evaluate(
201
+ f"""async (options) => {{
202
+ const results = await window.axe.run({context}, options);
203
+ return results;
204
+ }}""",
205
+ options,
206
+ )
207
+
208
+ # Parse violations
209
+ violations = []
210
+ for v in results.get("violations", []):
211
+ violations.append(A11yViolation(
212
+ rule_id=v["id"],
213
+ impact=v.get("impact", "unknown"),
214
+ description=v.get("description", ""),
215
+ help_url=v.get("helpUrl", ""),
216
+ nodes=v.get("nodes", []),
217
+ ))
218
+
219
+ report = A11yReport(
220
+ url=page.url,
221
+ violations=violations,
222
+ passes=len(results.get("passes", [])),
223
+ inapplicable=len(results.get("inapplicable", [])),
224
+ incomplete=len(results.get("incomplete", [])),
225
+ )
226
+
227
+ logger.info(report.summary())
228
+ return report
@@ -0,0 +1,288 @@
1
+ """HTTP API driver using httpx.
2
+
3
+ Wraps httpx with JWT token management, response assertions, and retry logic::
4
+
5
+ from ankole.driver.api_driver import APIDriver
6
+
7
+ with APIDriver(base_url="http://localhost:8000") as api:
8
+ api.login("admin", "admin123")
9
+ resp = api.get("/api/members")
10
+ resp.assert_status(200)
11
+ members = resp.json()
12
+ """
13
+
14
+ import logging
15
+ from dataclasses import dataclass, field
16
+ from typing import Any
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ @dataclass
22
+ class APIResponse:
23
+ """Wrapper around httpx.Response with assertion helpers."""
24
+
25
+ status_code: int
26
+ headers: dict
27
+ _json: Any = None
28
+ _text: str = ""
29
+
30
+ def json(self) -> Any:
31
+ """Return parsed JSON body."""
32
+ return self._json
33
+
34
+ @property
35
+ def text(self) -> str:
36
+ """Return response body as text."""
37
+ return self._text
38
+
39
+ def assert_status(self, expected: int) -> "APIResponse":
40
+ """Assert response status code."""
41
+ assert self.status_code == expected, (
42
+ f"Expected status {expected}, got {self.status_code}: {self._text[:200]}"
43
+ )
44
+ return self
45
+
46
+ def assert_json_key(self, key: str, value: Any = None) -> "APIResponse":
47
+ """Assert JSON response contains a key, optionally with specific value."""
48
+ data = self.json()
49
+ assert key in data, f"Key '{key}' not found in response: {data}"
50
+ if value is not None:
51
+ assert data[key] == value, (
52
+ f"Expected {key}={value!r}, got {data[key]!r}"
53
+ )
54
+ return self
55
+
56
+ def assert_json_key_absent(self, key: str) -> "APIResponse":
57
+ """Assert JSON response does NOT contain a key."""
58
+ data = self.json()
59
+ assert key not in data, f"Key '{key}' should not be in response but found: {key}={data[key]!r}"
60
+ return self
61
+
62
+ def assert_json_list_length(self, min_length: int = 0) -> "APIResponse":
63
+ """Assert JSON response is a list with minimum length."""
64
+ data = self.json()
65
+ assert isinstance(data, list), f"Expected list, got {type(data).__name__}"
66
+ assert len(data) >= min_length, (
67
+ f"Expected at least {min_length} items, got {len(data)}"
68
+ )
69
+ return self
70
+
71
+ def assert_schema(self, model_class: Any) -> "APIResponse":
72
+ """Validate response JSON against a Pydantic v2 model.
73
+
74
+ Args:
75
+ model_class: Pydantic BaseModel subclass.
76
+
77
+ Returns:
78
+ self for chaining.
79
+
80
+ Raises:
81
+ AssertionError: If validation fails.
82
+ """
83
+ data = self.json()
84
+ try:
85
+ model_class.model_validate(data)
86
+ except Exception as e:
87
+ raise AssertionError(
88
+ f"Schema validation failed for {model_class.__name__}: {e}"
89
+ ) from e
90
+ return self
91
+
92
+ def assert_json_schema(self, schema: dict) -> "APIResponse":
93
+ """Validate response JSON against a JSON Schema dict.
94
+
95
+ Args:
96
+ schema: JSON Schema dictionary.
97
+
98
+ Returns:
99
+ self for chaining.
100
+ """
101
+ import jsonschema
102
+
103
+ data = self.json()
104
+ try:
105
+ jsonschema.validate(instance=data, schema=schema)
106
+ except jsonschema.ValidationError as e:
107
+ raise AssertionError(
108
+ f"JSON Schema validation failed: {e.message}"
109
+ ) from e
110
+ return self
111
+
112
+ def assert_matches_openapi(self, spec_path: str, operation_id: str) -> "APIResponse":
113
+ """Validate response against an OpenAPI specification.
114
+
115
+ Loads the OpenAPI spec, finds the operation by ID, extracts the
116
+ response schema for the current status code, and validates.
117
+
118
+ Args:
119
+ spec_path: Path to OpenAPI spec file (YAML or JSON).
120
+ operation_id: Operation ID to match.
121
+
122
+ Returns:
123
+ self for chaining.
124
+ """
125
+ import json as json_mod
126
+ import yaml
127
+ import jsonschema
128
+
129
+ with open(spec_path, "r") as f:
130
+ if spec_path.endswith(".json"):
131
+ spec = json_mod.load(f)
132
+ else:
133
+ spec = yaml.safe_load(f)
134
+
135
+ # Find operation by operationId
136
+ response_schema = None
137
+ for path_obj in spec.get("paths", {}).values():
138
+ for method_obj in path_obj.values():
139
+ if isinstance(method_obj, dict) and method_obj.get("operationId") == operation_id:
140
+ status_str = str(self.status_code)
141
+ resp_def = method_obj.get("responses", {}).get(status_str, {})
142
+ content = resp_def.get("content", {})
143
+ json_content = content.get("application/json", {})
144
+ response_schema = json_content.get("schema")
145
+ break
146
+ if response_schema:
147
+ break
148
+
149
+ if response_schema is None:
150
+ raise AssertionError(
151
+ f"No schema found for operation '{operation_id}' "
152
+ f"with status {self.status_code} in {spec_path}"
153
+ )
154
+
155
+ try:
156
+ jsonschema.validate(instance=self.json(), schema=response_schema)
157
+ except jsonschema.ValidationError as e:
158
+ raise AssertionError(
159
+ f"OpenAPI validation failed for '{operation_id}': {e.message}"
160
+ ) from e
161
+ return self
162
+
163
+
164
+ class APIDriver:
165
+ """HTTP client wrapper with JWT token management.
166
+
167
+ Supports context manager protocol for automatic cleanup.
168
+ """
169
+
170
+ def __init__(
171
+ self,
172
+ base_url: str = "http://localhost:8000",
173
+ timeout: float = 30.0,
174
+ token_endpoint: str = "/api/auth/login",
175
+ token_field: str = "access_token",
176
+ ):
177
+ self.base_url = base_url.rstrip("/")
178
+ self.timeout = timeout
179
+ self.token_endpoint = token_endpoint
180
+ self.token_field = token_field
181
+
182
+ self._client = None
183
+ self._token: str | None = None
184
+
185
+ def start(self) -> "APIDriver":
186
+ """Create httpx client."""
187
+ import httpx
188
+
189
+ self._client = httpx.Client(
190
+ base_url=self.base_url,
191
+ timeout=self.timeout,
192
+ )
193
+ logger.info(f"APIDriver started: {self.base_url}")
194
+ return self
195
+
196
+ def close(self) -> None:
197
+ """Close httpx client."""
198
+ if self._client:
199
+ self._client.close()
200
+ self._token = None
201
+ logger.info("APIDriver closed")
202
+
203
+ def __enter__(self) -> "APIDriver":
204
+ return self.start()
205
+
206
+ def __exit__(self, *args) -> None:
207
+ self.close()
208
+
209
+ # -- Auth -----------------------------------------------------------------
210
+
211
+ def login(self, username: str, password: str) -> APIResponse:
212
+ """Authenticate and store JWT token."""
213
+ resp = self.post(
214
+ self.token_endpoint,
215
+ json={"username": username, "password": password},
216
+ )
217
+ if resp.status_code == 200:
218
+ data = resp.json()
219
+ self._token = data.get(self.token_field)
220
+ logger.info(f"Logged in as: {username}")
221
+ return resp
222
+
223
+ def logout(self) -> None:
224
+ """Clear stored token."""
225
+ self._token = None
226
+ logger.info("Logged out (token cleared)")
227
+
228
+ @property
229
+ def is_authenticated(self) -> bool:
230
+ """Check if a token is stored."""
231
+ return self._token is not None
232
+
233
+ # -- HTTP methods ---------------------------------------------------------
234
+
235
+ def _headers(self) -> dict:
236
+ """Build request headers with auth token if available."""
237
+ headers = {"Content-Type": "application/json"}
238
+ if self._token:
239
+ headers["Authorization"] = f"Bearer {self._token}"
240
+ return headers
241
+
242
+ def _wrap_response(self, resp) -> APIResponse:
243
+ """Convert httpx.Response to APIResponse."""
244
+ try:
245
+ json_data = resp.json()
246
+ except Exception:
247
+ json_data = None
248
+ return APIResponse(
249
+ status_code=resp.status_code,
250
+ headers=dict(resp.headers),
251
+ _json=json_data,
252
+ _text=resp.text,
253
+ )
254
+
255
+ def get(self, path: str, **kwargs) -> APIResponse:
256
+ """Send GET request."""
257
+ kwargs.setdefault("headers", self._headers())
258
+ resp = self._client.get(path, **kwargs)
259
+ logger.debug(f"GET {path} -> {resp.status_code}")
260
+ return self._wrap_response(resp)
261
+
262
+ def post(self, path: str, **kwargs) -> APIResponse:
263
+ """Send POST request."""
264
+ kwargs.setdefault("headers", self._headers())
265
+ resp = self._client.post(path, **kwargs)
266
+ logger.debug(f"POST {path} -> {resp.status_code}")
267
+ return self._wrap_response(resp)
268
+
269
+ def put(self, path: str, **kwargs) -> APIResponse:
270
+ """Send PUT request."""
271
+ kwargs.setdefault("headers", self._headers())
272
+ resp = self._client.put(path, **kwargs)
273
+ logger.debug(f"PUT {path} -> {resp.status_code}")
274
+ return self._wrap_response(resp)
275
+
276
+ def patch(self, path: str, **kwargs) -> APIResponse:
277
+ """Send PATCH request."""
278
+ kwargs.setdefault("headers", self._headers())
279
+ resp = self._client.patch(path, **kwargs)
280
+ logger.debug(f"PATCH {path} -> {resp.status_code}")
281
+ return self._wrap_response(resp)
282
+
283
+ def delete(self, path: str, **kwargs) -> APIResponse:
284
+ """Send DELETE request."""
285
+ kwargs.setdefault("headers", self._headers())
286
+ resp = self._client.delete(path, **kwargs)
287
+ logger.debug(f"DELETE {path} -> {resp.status_code}")
288
+ return self._wrap_response(resp)