testy-pytest-adapter 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.
testy.py ADDED
@@ -0,0 +1,3 @@
1
+ from testy_pytest_adapter import attach # noqa: F401
2
+
3
+ __all__ = ["attach"]
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from . import attachments as _attachments
4
+
5
+ __all__ = ["attach"]
6
+
7
+
8
+ def attach(path) -> None:
9
+ _attachments.register(path)
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ _SUITE_ORDER = {"parentSuite": 0, "suite": 1, "subSuite": 2}
4
+
5
+
6
+ def suite_path(item) -> list[str]:
7
+ found: dict[int, str] = {}
8
+ for marker in item.iter_markers(name="allure_label"):
9
+ label_type = marker.kwargs.get("label_type")
10
+ if label_type in _SUITE_ORDER and marker.args:
11
+ found[_SUITE_ORDER[label_type]] = str(marker.args[0]).strip()
12
+ return [found[key] for key in sorted(found) if found[key]]
13
+
14
+
15
+ def title(item) -> str | None:
16
+ name = getattr(getattr(item, "function", None), "__allure_display_name__", None)
17
+ return str(name) if name else None
18
+
19
+
20
+ def root_attribute_path(item) -> str:
21
+ parts = _nodeid_dir_parts(item.nodeid)
22
+ return parts[0] if parts else ""
23
+
24
+
25
+ def suite_attribute_paths(item, suites: list[str]) -> list[str]:
26
+ if not suites:
27
+ return []
28
+ parts = _nodeid_dir_parts(item.nodeid)
29
+ if not parts:
30
+ return ["" for _ in suites]
31
+
32
+ paths: list[str] = []
33
+ for index, _ in enumerate(suites):
34
+ if len(suites) == 1 or index == len(suites) - 1:
35
+ depth = len(parts)
36
+ elif index == 0:
37
+ depth = min(2, len(parts))
38
+ else:
39
+ depth = min(index + 2, len(parts))
40
+ paths.append("/".join(parts[:depth]))
41
+ return paths
42
+
43
+
44
+ def _nodeid_dir_parts(nodeid: str) -> list[str]:
45
+ path = str(nodeid).replace("\\", "/").split("::", 1)[0]
46
+ path = path.split("[", 1)[0].strip("/")
47
+ if not path:
48
+ return []
49
+ return [part for part in path.split("/")[:-1] if part]
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ _current: str | None = None
4
+ _pending: dict[str, list[str]] = {}
5
+
6
+
7
+ def set_current(nodeid: str | None) -> None:
8
+ global _current
9
+ _current = nodeid
10
+
11
+
12
+ def register(path) -> None:
13
+ if _current is None:
14
+ return
15
+ _pending.setdefault(_current, []).append(str(path))
16
+
17
+
18
+ def pop(nodeid: str) -> list[str]:
19
+ return _pending.pop(nodeid, [])
@@ -0,0 +1,558 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import mimetypes
6
+ import os
7
+ import datetime as dt
8
+
9
+ import requests
10
+
11
+ from .config import TestyConfig
12
+ from .models import CaseResult
13
+
14
+ log = logging.getLogger("testy_pytest")
15
+ _FAILURE_OUTCOMES = {"failed", "error", "xpassed"}
16
+
17
+ def _flatten_steps(tree: list) -> list[dict]:
18
+ steps: list[dict] = []
19
+
20
+ def walk(nodes: list) -> None:
21
+ for node in nodes:
22
+ title = node["title"]
23
+ steps.append({
24
+ "name": title[:255],
25
+ "scenario": title,
26
+ "expected": "",
27
+ "sort_order": len(steps) + 1,
28
+ })
29
+ if node.get("children"):
30
+ walk(node["children"])
31
+
32
+ walk(tree)
33
+ return steps
34
+
35
+
36
+ def _flatten_step_statuses(tree: list) -> list[str]:
37
+ statuses: list[str] = []
38
+
39
+ def walk(nodes: list) -> None:
40
+ for node in nodes:
41
+ statuses.append(str(node.get("status") or "passed"))
42
+ if node.get("children"):
43
+ walk(node["children"])
44
+
45
+ walk(tree)
46
+ return statuses
47
+
48
+
49
+ def _runtime_step_statuses(result: CaseResult) -> list[str]:
50
+ statuses = _flatten_step_statuses(result.steps)
51
+ if (
52
+ result.outcome in {"failed", "error", "xpassed"}
53
+ and statuses
54
+ and all(status.lower() != "failed" for status in statuses)
55
+ ):
56
+ statuses[-1] = "failed"
57
+ return statuses
58
+
59
+
60
+ def _steps_signature(steps: list[dict]) -> list[tuple[str, str, str]]:
61
+ ordered = sorted(
62
+ enumerate(steps),
63
+ key=lambda pair: int(pair[1].get("sort_order") or pair[0] + 1),
64
+ )
65
+ return [
66
+ (
67
+ str(step.get("name") or ""),
68
+ str(step.get("scenario") or ""),
69
+ str(step.get("expected") or ""),
70
+ )
71
+ for _, step in ordered
72
+ ]
73
+
74
+
75
+ def _ordered_steps(steps: list[dict]) -> list[dict]:
76
+ return [
77
+ step for _, step in sorted(
78
+ enumerate(steps),
79
+ key=lambda pair: int(pair[1].get("sort_order") or pair[0] + 1),
80
+ )
81
+ ]
82
+
83
+
84
+ def _step_results_payload(
85
+ case_steps: list[dict],
86
+ runtime_statuses: list[str],
87
+ passed_status_id: int,
88
+ failed_status_id: int,
89
+ ) -> list[dict]:
90
+ payload: list[dict] = []
91
+ for case_step, runtime_status in zip(case_steps, runtime_statuses):
92
+ step_id = case_step.get("id")
93
+ if step_id is None:
94
+ continue
95
+ status_id = failed_status_id if runtime_status.lower() == "failed" else passed_status_id
96
+ payload.append({"step": int(step_id), "status": int(status_id)})
97
+ return payload
98
+
99
+
100
+ def _variants_summary(result: CaseResult) -> str:
101
+ if len(result.variants) <= 1:
102
+ return ""
103
+ failed = [str(lbl) for lbl, oc in result.variants if oc in _FAILURE_OUTCOMES]
104
+ total = len(result.variants)
105
+ if failed:
106
+ return f"Параметризация: {total} вариант(ов), упали: {', '.join(failed)}"
107
+ return f"Параметризация: {total} вариант(ов), все пройдены"
108
+
109
+
110
+ def _compose_comment(result: CaseResult, limit: int = 5000) -> str:
111
+ parts: list[str] = []
112
+ summary = _variants_summary(result)
113
+ if summary:
114
+ parts.append(summary)
115
+ if result.comment:
116
+ prefix, suffix = "```text\n", "\n```"
117
+ budget = max(limit - len(prefix) - len(suffix) - len("\n\n".join(parts)) - 2, 0)
118
+ body = result.comment.strip()[:budget]
119
+ parts.append(f"{prefix}{body}{suffix}")
120
+ return "\n\n".join(parts)
121
+
122
+
123
+ class TestyClient:
124
+ __test__ = False # not a pytest test class despite the Test* name
125
+
126
+ def __init__(self, config: TestyConfig):
127
+ self.cfg = config
128
+ self.session = requests.Session()
129
+ self.session.headers["Authorization"] = f"{config.auth_scheme} {config.token}"
130
+ self._status_ids: dict[str, int] = {}
131
+ self._test_by_case: dict[int, int] = {}
132
+ self._case_by_external: dict[str, int | None] = {}
133
+ self._suite_cache: dict[tuple, int] = {}
134
+ self._suite_attrs_cache: set[tuple[int, str]] = set()
135
+ self._plan_cache: dict[tuple, int] = {}
136
+
137
+
138
+ def _get(self, path: str, **params):
139
+ resp = self.session.get(
140
+ f"{self.cfg.api}/{path.lstrip('/')}",
141
+ params=params,
142
+ timeout=self.cfg.timeout,
143
+ verify=self.cfg.verify_ssl,
144
+ )
145
+ resp.raise_for_status()
146
+ return resp.json()
147
+
148
+ def _paged(self, path: str, **params):
149
+ data = self._get(path, **params)
150
+ while True:
151
+ results = data["results"] if isinstance(data, dict) else data
152
+ yield from results
153
+ nxt = data.get("links", {}).get("next") if isinstance(data, dict) else None
154
+ nxt = nxt or (data.get("next") if isinstance(data, dict) else None)
155
+ if not nxt:
156
+ return
157
+ data = self._get(nxt.split("/api/v2/", 1)[-1])
158
+
159
+ def load_statuses(self) -> None:
160
+ data = self._get("statuses/", project=self.cfg.project_id)
161
+ results = data["results"] if isinstance(data, dict) else data
162
+ for status in results:
163
+ self._status_ids[status["name"].lower()] = status["id"]
164
+
165
+ def load_plan_tests(self) -> None:
166
+ self.ensure_roots(need_plan=True)
167
+ path = (
168
+ f"testplans/{self.cfg.plan_id}/tests/"
169
+ f"?project={self.cfg.project_id}&show_descendants=true"
170
+ )
171
+ for test in self._paged(path):
172
+ self._test_by_case[int(test["case"])] = int(test["id"])
173
+
174
+ def resolve_case_by_external_id(self, external_id: str) -> int | None:
175
+ if external_id in self._case_by_external:
176
+ return self._case_by_external[external_id]
177
+ attr_filter = json.dumps({self.cfg.automation_key: external_id})
178
+ data = self._get(
179
+ "cases/", project=self.cfg.project_id, case_attributes=attr_filter,
180
+ )
181
+ results = data["results"] if isinstance(data, dict) else data
182
+ case_id = int(results[0]["id"]) if results else None
183
+ if case_id is None:
184
+ log.warning("TestY: no case with %s=%r in project %s",
185
+ self.cfg.automation_key, external_id, self.cfg.project_id)
186
+ self._case_by_external[external_id] = case_id
187
+ return case_id
188
+
189
+
190
+ def ensure_roots(self, need_suite: bool = False, need_plan: bool = False) -> None:
191
+ """Resolve configured root suite/plan ids, creating them by name if needed."""
192
+ if need_suite and self.cfg.suite_id is None:
193
+ suite_name = self.cfg.suite_name or self.cfg.root_name
194
+ if not suite_name:
195
+ raise ValueError("TestY suite root is missing: set TESTY_SUITE_ID or TESTY_ROOT_NAME")
196
+ self.cfg.suite_id = self._ensure_child_suite(suite_name, None)
197
+ if self.cfg.suite_id is None:
198
+ raise RuntimeError(f"TestY: failed to resolve root suite {suite_name!r}")
199
+ if need_plan and self.cfg.plan_id is None:
200
+ plan_name = self.cfg.plan_name or self.cfg.root_name
201
+ if not plan_name:
202
+ raise ValueError("TestY plan root is missing: set TESTY_PLAN_ID or TESTY_ROOT_NAME")
203
+ self.cfg.plan_id = self._ensure_root_plan(plan_name)
204
+
205
+ def _ensure_root_plan(self, name: str) -> int:
206
+ key = (None, name)
207
+ if key in self._plan_cache:
208
+ return self._plan_cache[key]
209
+ for plan in self._paged("testplans/", project=self.cfg.project_id):
210
+ if plan.get("name") != name:
211
+ continue
212
+ parent = plan.get("parent") if isinstance(plan, dict) else None
213
+ if "parent" not in plan or parent in (None, "", "null"):
214
+ plan_id = int(plan["id"])
215
+ self._plan_cache[key] = plan_id
216
+ return plan_id
217
+ payload = self._plan_payload(name)
218
+ resp = self.session.post(f"{self.cfg.api}/testplans/", json=payload,
219
+ timeout=self.cfg.timeout, verify=self.cfg.verify_ssl)
220
+ if resp.status_code >= 400:
221
+ raise RuntimeError(
222
+ f"TestY: failed to create root plan {name!r}: "
223
+ f"{resp.status_code} {resp.text[:300]}"
224
+ )
225
+ body = resp.json()
226
+ obj = body[0] if isinstance(body, list) else body
227
+ plan_id = int(obj["id"])
228
+ self._plan_cache[key] = plan_id
229
+ log.info("TestY: created root plan %s %r", plan_id, name)
230
+ return plan_id
231
+
232
+ def ensure_suite_attributes(self, suite_id: int, attributes: dict) -> None:
233
+ self._ensure_suite_attributes(suite_id, attributes)
234
+
235
+ def ensure_suite_path(
236
+ self,
237
+ path: list[str],
238
+ root_id: int | None,
239
+ attribute_values: list[str] | None = None,
240
+ ) -> int | None:
241
+ parent = root_id
242
+ for index, name in enumerate(path):
243
+ attributes = None
244
+ if attribute_values and index < len(attribute_values):
245
+ attributes = {self.cfg.automation_key: attribute_values[index]}
246
+ parent = self._ensure_child_suite(name, parent, attributes=attributes)
247
+ if parent is None:
248
+ return root_id
249
+ return parent
250
+
251
+ def _ensure_child_suite(
252
+ self,
253
+ name: str,
254
+ parent_id: int | None,
255
+ attributes: dict | None = None,
256
+ ) -> int | None:
257
+ key = (parent_id, name)
258
+ if key in self._suite_cache:
259
+ suite_id = self._suite_cache[key]
260
+ self._ensure_suite_attributes(suite_id, attributes)
261
+ return suite_id
262
+ for suite in self._paged("suites/", project=self.cfg.project_id,
263
+ parent=parent_id if parent_id else "null"):
264
+ if suite.get("name") == name:
265
+ suite_id = int(suite["id"])
266
+ self._suite_cache[key] = suite_id
267
+ self._ensure_suite_attributes(suite_id, attributes, suite.get("attributes"))
268
+ return suite_id
269
+ payload = {"name": name[:255], "project": self.cfg.project_id}
270
+ if parent_id:
271
+ payload["parent"] = parent_id
272
+ if attributes:
273
+ payload["attributes"] = attributes
274
+ resp = self.session.post(f"{self.cfg.api}/suites/", json=payload,
275
+ timeout=self.cfg.timeout, verify=self.cfg.verify_ssl)
276
+ if resp.status_code >= 400:
277
+ log.error("TestY: failed to create suite %r under %s: %s %s",
278
+ name, parent_id, resp.status_code, resp.text[:300])
279
+ return None
280
+ body = resp.json()
281
+ suite_id = int((body[0] if isinstance(body, list) else body)["id"])
282
+ self._suite_cache[key] = suite_id
283
+ log.info("TestY: created suite %s %r under %s", suite_id, name, parent_id)
284
+ return suite_id
285
+
286
+ def _ensure_suite_attributes(
287
+ self,
288
+ suite_id: int | None,
289
+ attributes: dict | None,
290
+ current: dict | None = None,
291
+ ) -> None:
292
+ desired = {key: value for key, value in (attributes or {}).items() if value}
293
+ if not suite_id or not desired:
294
+ return
295
+ cache_key = (int(suite_id), json.dumps(desired, sort_keys=True, ensure_ascii=False))
296
+ if cache_key in self._suite_attrs_cache:
297
+ return
298
+ if current is None:
299
+ try:
300
+ suite = self._get(f"suites/{suite_id}/")
301
+ current = suite.get("attributes") or {}
302
+ except requests.HTTPError as exc:
303
+ log.warning("TestY: cannot read suite %s for attributes: %s", suite_id, exc)
304
+ return
305
+ merged = dict(current or {})
306
+ changed = False
307
+ for key, value in desired.items():
308
+ if merged.get(key) != value:
309
+ merged[key] = value
310
+ changed = True
311
+ if not changed:
312
+ self._suite_attrs_cache.add(cache_key)
313
+ return
314
+ resp = self.session.patch(
315
+ f"{self.cfg.api}/suites/{suite_id}/",
316
+ json={"attributes": merged},
317
+ timeout=self.cfg.timeout,
318
+ verify=self.cfg.verify_ssl,
319
+ )
320
+ if resp.status_code >= 400:
321
+ log.error("TestY: failed to update suite %s attributes: %s %s",
322
+ suite_id, resp.status_code, resp.text[:300])
323
+ return
324
+ self._suite_attrs_cache.add(cache_key)
325
+ log.info("TestY: updated suite %s attributes with %s", suite_id, sorted(desired))
326
+
327
+ def ensure_case(self, external_id: str, name: str, suite_id: int | None = None) -> int | None:
328
+ case_id = self.resolve_case_by_external_id(external_id)
329
+ if case_id is not None:
330
+ return case_id
331
+ target_suite = suite_id or self.cfg.suite_id
332
+ if not target_suite:
333
+ log.error("TestY: cannot auto-create case for %r — no suite (set --testy-suite)",
334
+ external_id)
335
+ return None
336
+ created = self.session.post(
337
+ f"{self.cfg.api}/cases/",
338
+ json={
339
+ "name": name[:255], "project": self.cfg.project_id,
340
+ "suite": target_suite, "scenario": "Created by testy-pytest sync",
341
+ "attributes": {self.cfg.automation_key: external_id},
342
+ },
343
+ timeout=self.cfg.timeout, verify=self.cfg.verify_ssl,
344
+ )
345
+ if created.status_code >= 400:
346
+ log.error("TestY: failed to create case for %r: %s %s",
347
+ external_id, created.status_code, created.text[:300])
348
+ return None
349
+ case_id = int(created.json()["id"])
350
+ self._case_by_external[external_id] = case_id
351
+ log.info("TestY: created case %s for %r", case_id, external_id)
352
+ return case_id
353
+
354
+ def sync_plan(self, case_ids: list[int], plan_id: int | None = None) -> None:
355
+ self.ensure_roots(need_plan=True)
356
+ target_plan = plan_id or self.cfg.plan_id
357
+ if target_plan is None:
358
+ raise ValueError("TestY plan root is missing")
359
+ existing = self._load_tests_for_single_plan(target_plan)
360
+ to_add = [cid for cid in case_ids if cid not in existing]
361
+ added = 0
362
+ for case_id in to_add:
363
+ resp = self.session.post(
364
+ f"{self.cfg.api}/tests/",
365
+ json={"project": self.cfg.project_id, "case": case_id,
366
+ "plan": target_plan},
367
+ timeout=self.cfg.timeout, verify=self.cfg.verify_ssl,
368
+ )
369
+ if resp.status_code >= 400:
370
+ log.error("TestY: failed to add case %s to plan %s: %s %s",
371
+ case_id, target_plan, resp.status_code, resp.text[:300])
372
+ continue
373
+ body = resp.json()
374
+ obj = body[0] if isinstance(body, list) else body
375
+ self._test_by_case[case_id] = int(obj["id"])
376
+ added += 1
377
+ if added:
378
+ log.info("TestY: added %s case(s) to plan %s (%s already present)",
379
+ added, target_plan, len(existing))
380
+
381
+ def _load_tests_for_single_plan(self, plan_id: int) -> dict[int, int]:
382
+ tests: dict[int, int] = {}
383
+ for test in self._paged(f"tests/?plan={plan_id}&project={self.cfg.project_id}"):
384
+ tests[int(test["case"])] = int(test["id"])
385
+ return tests
386
+
387
+ def ensure_plan_path(self, path: list[str], root_id: int) -> int:
388
+ parent = root_id
389
+ for name in path:
390
+ parent = self._ensure_child_plan(name, parent)
391
+ return parent
392
+
393
+ def _ensure_child_plan(self, name: str, parent_id: int) -> int:
394
+ key = (parent_id, name)
395
+ if key in self._plan_cache:
396
+ return self._plan_cache[key]
397
+ for plan in self._paged("testplans/", project=self.cfg.project_id, parent=parent_id):
398
+ if plan.get("name") == name:
399
+ self._plan_cache[key] = int(plan["id"])
400
+ return int(plan["id"])
401
+ payload = self._plan_payload(name, parent_id)
402
+ resp = self.session.post(f"{self.cfg.api}/testplans/", json=payload,
403
+ timeout=self.cfg.timeout, verify=self.cfg.verify_ssl)
404
+ if resp.status_code >= 400:
405
+ log.error("TestY: failed to create plan %r under %s: %s %s",
406
+ name, parent_id, resp.status_code, resp.text[:300])
407
+ return parent_id
408
+ body = resp.json()
409
+ obj = body[0] if isinstance(body, list) else body
410
+ plan_id = int(obj["id"])
411
+ self._plan_cache[key] = plan_id
412
+ log.info("TestY: created plan %s %r under %s", plan_id, name, parent_id)
413
+ return plan_id
414
+
415
+ def _plan_payload(self, name: str, parent_id: int | None = None) -> dict:
416
+ now = dt.datetime.now(dt.timezone.utc)
417
+ payload = {
418
+ "name": name[:255],
419
+ "project": self.cfg.project_id,
420
+ "started_at": now.isoformat(),
421
+ "due_date": (now + dt.timedelta(days=7)).isoformat(),
422
+ "test_cases": [],
423
+ }
424
+ if parent_id:
425
+ payload["parent"] = parent_id
426
+ return payload
427
+
428
+
429
+ def report(self, result: CaseResult) -> bool:
430
+ if not result.external_id:
431
+ return False
432
+ case_id = self.resolve_case_by_external_id(result.external_id)
433
+ if case_id is None:
434
+ return False
435
+
436
+ test_id = self._test_by_case.get(case_id)
437
+ if test_id is None:
438
+ log.warning(
439
+ "TestY: case %s is not part of plan %s — skipping result for %s",
440
+ case_id, self.cfg.plan_id, result.nodeid,
441
+ )
442
+ return False
443
+
444
+ status_name = self.cfg.status_name_for(result.outcome)
445
+ status_id = self._status_ids.get(status_name.lower())
446
+ if status_id is None:
447
+ log.warning("TestY: status %r not found in project %s (available: %s)",
448
+ status_name, self.cfg.project_id, sorted(self._status_ids))
449
+ return False
450
+
451
+ attributes = {"source": "gitlab-ci", "nodeid": result.nodeid}
452
+ if self.cfg.ci_pipeline_url:
453
+ attributes["ci_pipeline_url"] = self.cfg.ci_pipeline_url
454
+ if self.cfg.ci_job_url:
455
+ attributes["ci_job_url"] = self.cfg.ci_job_url
456
+
457
+ attachment_ids: list[int] = []
458
+ if result.attachments and self.cfg.should_attach(result.outcome):
459
+ attachment_ids = self._upload_attachments(result.attachments)
460
+
461
+ steps_results: list[dict] = []
462
+ if result.steps:
463
+ case_steps = self._sync_case_steps(case_id, result.steps)
464
+ passed_status_id = self._status_ids.get(self.cfg.status_name_for("passed").lower())
465
+ failed_status_id = self._status_ids.get(self.cfg.status_name_for("failed").lower())
466
+ if case_steps and passed_status_id is not None and failed_status_id is not None:
467
+ steps_results = _step_results_payload(
468
+ case_steps,
469
+ _runtime_step_statuses(result),
470
+ passed_status_id,
471
+ failed_status_id,
472
+ )
473
+
474
+ payload = {
475
+ "test": test_id,
476
+ "status": status_id,
477
+ "project": self.cfg.project_id,
478
+ "comment": _compose_comment(result),
479
+ "execution_time": result.execution_time,
480
+ "attributes": attributes,
481
+ }
482
+ if attachment_ids:
483
+ payload["attachments"] = attachment_ids
484
+ if steps_results:
485
+ payload["steps_results"] = steps_results
486
+ resp = self.session.post(
487
+ f"{self.cfg.api}/results/",
488
+ json=payload,
489
+ timeout=self.cfg.timeout,
490
+ verify=self.cfg.verify_ssl,
491
+ )
492
+ if resp.status_code >= 400:
493
+ log.error("TestY: failed to post result for %s: %s %s",
494
+ result.nodeid, resp.status_code, resp.text[:500])
495
+ return False
496
+ return True
497
+
498
+ def _sync_case_steps(self, case_id: int, steps_tree: list) -> list[dict]:
499
+ try:
500
+ case = self._get(f"cases/{case_id}/")
501
+ except requests.HTTPError as exc:
502
+ log.warning("TestY: cannot read case %s for steps: %s", case_id, exc)
503
+ return []
504
+ desired_steps = _flatten_steps(steps_tree)
505
+ current_steps = case.get("steps") or []
506
+ if current_steps and not self.cfg.override_cases:
507
+ return _ordered_steps(current_steps) # frozen: never overwrite
508
+ if _steps_signature(current_steps) == _steps_signature(desired_steps):
509
+ return _ordered_steps(current_steps)
510
+ suite = case.get("suite")
511
+ suite_id = suite["id"] if isinstance(suite, dict) else suite
512
+ payload = {
513
+ "name": case["name"], "project": self.cfg.project_id, "suite": suite_id,
514
+ "setup": case.get("setup", ""), "scenario": case.get("scenario", ""),
515
+ "expected": case.get("expected", ""), "teardown": case.get("teardown", ""),
516
+ "description": case.get("description", ""),
517
+ "attributes": case.get("attributes", {}),
518
+ "is_steps": True, "steps": desired_steps,
519
+ }
520
+ resp = self.session.put(f"{self.cfg.api}/cases/{case_id}/", json=payload,
521
+ timeout=self.cfg.timeout, verify=self.cfg.verify_ssl)
522
+ if resp.status_code >= 400:
523
+ log.error("TestY: failed to write steps to case %s: %s %s",
524
+ case_id, resp.status_code, resp.text[:300])
525
+ return []
526
+ action = "updated" if current_steps else "wrote"
527
+ log.info("TestY: %s %s step(s) into case %s",
528
+ action, len(payload["steps"]), case_id)
529
+ try:
530
+ updated_case = self._get(f"cases/{case_id}/")
531
+ except requests.HTTPError as exc:
532
+ log.warning("TestY: cannot re-read case %s after writing steps: %s", case_id, exc)
533
+ return []
534
+ return _ordered_steps(updated_case.get("steps") or [])
535
+
536
+ def _upload_attachments(self, paths: list[str]) -> list[int]:
537
+ ids: list[int] = []
538
+ for path in paths:
539
+ if not os.path.isfile(path):
540
+ log.warning("TestY: attachment not found, skipping: %s", path)
541
+ continue
542
+ content_type = mimetypes.guess_type(path)[0] or "application/octet-stream"
543
+ with open(path, "rb") as fh:
544
+ resp = self.session.post(
545
+ f"{self.cfg.api}/attachments/",
546
+ data={"project": self.cfg.project_id},
547
+ files={"file": (os.path.basename(path), fh, content_type)},
548
+ timeout=self.cfg.timeout,
549
+ verify=self.cfg.verify_ssl,
550
+ )
551
+ if resp.status_code >= 400:
552
+ log.error("TestY: failed to upload %s: %s %s",
553
+ path, resp.status_code, resp.text[:300])
554
+ continue
555
+ body = resp.json()
556
+ obj = body[0] if isinstance(body, list) else body
557
+ ids.append(int(obj["id"]))
558
+ return ids