testops-mirror 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.
@@ -0,0 +1,3 @@
1
+ """testops-mirror: mirror TMS test cases into a Git repository."""
2
+
3
+ __version__ = "0.1.0"
testops_mirror/cli.py ADDED
@@ -0,0 +1,149 @@
1
+ """Command-line interface for testops-mirror."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ from typing import Annotated
8
+
9
+ import typer
10
+ from dotenv import load_dotenv
11
+ from rich.console import Console
12
+ from rich.progress import Progress, SpinnerColumn, TextColumn
13
+
14
+ from testops_mirror.connectors.allure_testops import AllureTestOpsConnector
15
+ from testops_mirror.exceptions import AuthError, TestopsMirrorError
16
+ from testops_mirror.gitstore import GitStore
17
+ from testops_mirror.models import TestCase
18
+ from testops_mirror.sync import run_sync
19
+
20
+ app = typer.Typer(
21
+ name="testops-mirror",
22
+ help="Mirror test cases from a TMS into a Git repository.",
23
+ add_completion=False,
24
+ )
25
+ console = Console()
26
+ err_console = Console(stderr=True)
27
+
28
+
29
+ _ProjectId = Annotated[
30
+ str, typer.Option("--project-id", help="TMS project ID.", envvar="TESTOPS_PROJECT_ID")
31
+ ]
32
+ _Endpoint = Annotated[
33
+ str, typer.Option("--endpoint", help="Base URL of the TMS.", envvar="TESTOPS_ENDPOINT")
34
+ ]
35
+ _Token = Annotated[str, typer.Option("--token", help="API token.", envvar="TESTOPS_TOKEN")]
36
+ _Repo = Annotated[str, typer.Option("--repo", help="Local git repository path.")]
37
+ _SuiteField = Annotated[
38
+ str,
39
+ typer.Option(
40
+ "--suite-field",
41
+ help="Custom field for folder structure.",
42
+ envvar="TESTOPS_SUITE_FIELD",
43
+ ),
44
+ ]
45
+ _DryRun = Annotated[bool, typer.Option("--dry-run", help="Preview changes without writing.")]
46
+ _Verbose = Annotated[bool, typer.Option("-v", "--verbose", help="Verbose logging.")]
47
+
48
+
49
+ @app.command()
50
+ def sync(
51
+ project_id: _ProjectId,
52
+ endpoint: _Endpoint,
53
+ token: _Token,
54
+ repo: _Repo = "./mirror",
55
+ suite_field: _SuiteField = "Suite",
56
+ dry_run: _DryRun = False,
57
+ verbose: _Verbose = False,
58
+ ) -> None:
59
+ """Mirror all test cases from a TMS project into a local Git repository."""
60
+ load_dotenv()
61
+
62
+ logging.basicConfig(
63
+ level=logging.DEBUG if verbose else logging.WARNING,
64
+ format="%(levelname)s %(name)s: %(message)s",
65
+ )
66
+
67
+ connector = AllureTestOpsConnector(
68
+ endpoint=endpoint,
69
+ api_token=token,
70
+ suite_field=suite_field,
71
+ )
72
+ store = GitStore(repo)
73
+
74
+ fetched: list[TestCase] = []
75
+
76
+ def _on_case(case: TestCase) -> None:
77
+ fetched.append(case)
78
+ if len(fetched) % 50 == 0:
79
+ logging.getLogger(__name__).info("Fetched %d cases so far...", len(fetched))
80
+
81
+ try:
82
+ if dry_run:
83
+ with Progress(
84
+ SpinnerColumn(),
85
+ TextColumn("[progress.description]{task.description}"),
86
+ console=console,
87
+ transient=True,
88
+ ) as progress:
89
+ task = progress.add_task("Fetching test cases...", total=None)
90
+ changes, _ = run_sync(
91
+ connector,
92
+ store,
93
+ project_id,
94
+ dry_run=True,
95
+ on_case=_on_case,
96
+ )
97
+ progress.update(task, completed=True)
98
+
99
+ console.print(f"Found [bold]{len(fetched)}[/bold] test cases")
100
+ console.print()
101
+
102
+ for path in changes.added:
103
+ console.print(f"[green]+[/green] {path}")
104
+ for path in changes.updated:
105
+ console.print(f"[yellow]~[/yellow] {path}")
106
+ for path in changes.deleted:
107
+ console.print(f"[red]-[/red] {path}")
108
+
109
+ if not changes.empty:
110
+ console.print()
111
+ console.print(
112
+ f"[bold]{len(changes.added)}[/bold] to add, "
113
+ f"[bold]{len(changes.updated)}[/bold] to update, "
114
+ f"[bold]{len(changes.deleted)}[/bold] to delete"
115
+ )
116
+
117
+ else:
118
+ with Progress(
119
+ SpinnerColumn(),
120
+ TextColumn("[progress.description]{task.description}"),
121
+ console=console,
122
+ ) as progress:
123
+ task = progress.add_task("Fetching test cases...", total=None)
124
+ changes, sha = run_sync(
125
+ connector,
126
+ store,
127
+ project_id,
128
+ on_case=_on_case,
129
+ )
130
+ progress.update(
131
+ task,
132
+ description=f"Fetched {len(fetched)} test cases",
133
+ completed=True,
134
+ )
135
+
136
+ if sha:
137
+ console.print(f"[green]Committed[/green] {sha[:8]} — {changes.summary()}")
138
+ else:
139
+ console.print("[dim]Nothing to commit[/dim]")
140
+
141
+ except AuthError as exc:
142
+ err_console.print(f"[red]Authentication failed:[/red] {exc}")
143
+ raise typer.Exit(1) from exc
144
+ except TestopsMirrorError as exc:
145
+ err_console.print(f"[red]Error:[/red] {exc}")
146
+ raise typer.Exit(1) from exc
147
+ except KeyboardInterrupt:
148
+ err_console.print("\n[yellow]Interrupted[/yellow]")
149
+ sys.exit(130)
@@ -0,0 +1 @@
1
+ """TMS connector implementations."""
@@ -0,0 +1,380 @@
1
+ """Allure TestOps connector.
2
+
3
+ Implements the TmsConnector protocol for Allure TestOps instances.
4
+
5
+ API paths are defined as module-level constants — verify them against your
6
+ instance's Swagger UI (<endpoint>/swagger-ui/) as paths may differ between
7
+ TestOps versions.
8
+
9
+ Authentication flow (confirmed by official docs):
10
+ POST /api/uaa/oauth/token (form: grant_type=apitoken, scope=openid, token=<api_token>)
11
+ -> { access_token, expires_in }
12
+ The JWT is then used as Bearer for all /api/rs/* requests.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import time
19
+ from collections.abc import Iterator
20
+ from typing import Any
21
+
22
+ import httpx
23
+
24
+ from testops_mirror.exceptions import AuthError, ConnectorError, NotFoundError, RateLimitError
25
+ from testops_mirror.models import Link, Step, TestCase
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # API path constants
31
+ # Verify against <endpoint>/swagger-ui/ for your TestOps version.
32
+ # ---------------------------------------------------------------------------
33
+ _PATH_TOKEN = "/api/uaa/oauth/token"
34
+ _PATH_TESTCASE_LIST = "/api/rs/testcase" # ?projectId=&page=&size=
35
+ _PATH_TESTCASE_DETAIL = "/api/rs/testcase/{id}"
36
+ _PATH_TESTCASE_SCENARIO = "/api/rs/testcase/{id}/scenario"
37
+ _PATH_TESTCASE_STEP = "/api/rs/testcase/{id}/step"
38
+
39
+ _PAGE_SIZE = 100
40
+ _RETRY_STATUSES = {429, 500, 502, 503, 504}
41
+ _MAX_RETRIES = 3
42
+ _RETRY_BASE_DELAY = 1.0 # seconds; doubled on each attempt
43
+ _TOKEN_REFRESH_BUFFER = 60 # seconds before expiry to proactively refresh
44
+
45
+
46
+ class AllureTestOpsConnector:
47
+ """Connector for Allure TestOps.
48
+
49
+ Parameters
50
+ ----------
51
+ endpoint:
52
+ Base URL of the TestOps instance, e.g. ``https://testops.example.com``.
53
+ api_token:
54
+ API token generated in TestOps → Profile → API tokens.
55
+ suite_field:
56
+ Name of the custom field used to derive the folder hierarchy.
57
+ Values like ``"Shipments/Negative"`` are split on ``/`` to produce
58
+ nested directories.
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ endpoint: str,
64
+ api_token: str,
65
+ suite_field: str = "Suite",
66
+ *,
67
+ http_client: httpx.Client | None = None,
68
+ ) -> None:
69
+ self._endpoint = endpoint.rstrip("/")
70
+ self._api_token = api_token
71
+ self._suite_field = suite_field
72
+ self._client = http_client or httpx.Client(timeout=30)
73
+ self._jwt: str | None = None
74
+ self._jwt_expires_at: float = 0.0
75
+
76
+ # ------------------------------------------------------------------
77
+ # Authentication
78
+ # ------------------------------------------------------------------
79
+
80
+ def _authenticate(self) -> None:
81
+ """Exchange the API token for a short-lived JWT."""
82
+ url = self._endpoint + _PATH_TOKEN
83
+ try:
84
+ resp = self._client.post(
85
+ url,
86
+ data={
87
+ "grant_type": "apitoken",
88
+ "scope": "openid",
89
+ "token": self._api_token,
90
+ },
91
+ )
92
+ except httpx.HTTPError as exc:
93
+ raise ConnectorError(f"Auth request failed: {exc}") from exc
94
+
95
+ if resp.status_code in (401, 403):
96
+ raise AuthError(f"Authentication failed ({resp.status_code}): {resp.text}")
97
+ if not resp.is_success:
98
+ raise ConnectorError(f"Auth endpoint returned {resp.status_code}: {resp.text}")
99
+
100
+ data = resp.json()
101
+ self._jwt = data["access_token"]
102
+ expires_in: int = data.get("expires_in", 3600)
103
+ self._jwt_expires_at = time.monotonic() + expires_in
104
+
105
+ def _get_bearer(self) -> str:
106
+ """Return a valid JWT, refreshing proactively if close to expiry."""
107
+ if self._jwt is None or time.monotonic() >= self._jwt_expires_at - _TOKEN_REFRESH_BUFFER:
108
+ self._authenticate()
109
+ assert self._jwt is not None
110
+ return self._jwt
111
+
112
+ # ------------------------------------------------------------------
113
+ # HTTP with retry
114
+ # ------------------------------------------------------------------
115
+
116
+ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
117
+ """Send an authenticated request with retry logic.
118
+
119
+ Retries on 429 and 5xx up to _MAX_RETRIES times using exponential
120
+ backoff. On 401 attempts one token refresh before retrying.
121
+ Raises typed exceptions for all terminal error conditions.
122
+ """
123
+ url = self._endpoint + path
124
+ delay = _RETRY_BASE_DELAY
125
+ last_exc: Exception | None = None
126
+
127
+ for attempt in range(_MAX_RETRIES):
128
+ headers = {"Authorization": f"Bearer {self._get_bearer()}"}
129
+ try:
130
+ resp = self._client.request(method, url, headers=headers, **kwargs)
131
+ except httpx.HTTPError as exc:
132
+ last_exc = exc
133
+ if attempt < _MAX_RETRIES - 1:
134
+ time.sleep(delay)
135
+ delay *= 2
136
+ continue
137
+
138
+ if resp.status_code == 401:
139
+ if attempt < _MAX_RETRIES - 1:
140
+ # Force token refresh and retry once
141
+ self._jwt = None
142
+ continue
143
+ raise AuthError(f"Persistent 401 on {path}")
144
+
145
+ if resp.status_code == 403:
146
+ raise AuthError(f"Forbidden (403) on {path}")
147
+
148
+ if resp.status_code == 404:
149
+ raise NotFoundError(f"Resource not found: {path}")
150
+
151
+ if resp.status_code == 429:
152
+ retry_after = float(resp.headers.get("Retry-After", delay))
153
+ logger.warning("Rate limited (429); sleeping %.1fs", retry_after)
154
+ if attempt < _MAX_RETRIES - 1:
155
+ time.sleep(retry_after)
156
+ delay *= 2
157
+ last_exc = RateLimitError(f"Rate limit on {path}")
158
+ continue
159
+ raise RateLimitError(f"Rate limit exceeded after {_MAX_RETRIES} attempts on {path}")
160
+
161
+ if resp.status_code >= 500:
162
+ logger.warning(
163
+ "Server error %d on %s; attempt %d/%d",
164
+ resp.status_code,
165
+ path,
166
+ attempt + 1,
167
+ _MAX_RETRIES,
168
+ )
169
+ if attempt < _MAX_RETRIES - 1:
170
+ time.sleep(delay)
171
+ delay *= 2
172
+ last_exc = ConnectorError(f"Server error {resp.status_code} on {path}")
173
+ continue
174
+ raise ConnectorError(
175
+ f"Server error {resp.status_code} after {_MAX_RETRIES} attempts on {path}"
176
+ )
177
+
178
+ if not resp.is_success:
179
+ raise ConnectorError(f"Unexpected {resp.status_code} on {path}: {resp.text}")
180
+
181
+ return resp
182
+
183
+ if last_exc is not None:
184
+ raise ConnectorError(f"All {_MAX_RETRIES} attempts failed for {path}") from last_exc
185
+ raise ConnectorError(f"Request failed for {path}") # unreachable, satisfies mypy
186
+
187
+ # ------------------------------------------------------------------
188
+ # Steps — fallback chain
189
+ # ------------------------------------------------------------------
190
+
191
+ def _fetch_steps(self, case_id: str) -> list[dict[str, Any]]:
192
+ """Fetch steps using a fallback chain.
193
+
194
+ Known bug in some TestOps versions: /scenario returns empty steps.
195
+ See: github.com/orgs/allure-framework/discussions/3190
196
+ Try /scenario first; if the result is empty fall back to /step.
197
+ 4xx responses are silently ignored (return empty list).
198
+ """
199
+ for path_tpl in (_PATH_TESTCASE_SCENARIO, _PATH_TESTCASE_STEP):
200
+ path = path_tpl.format(id=case_id)
201
+ try:
202
+ resp = self._request("GET", path)
203
+ data = resp.json()
204
+ steps: list[dict[str, Any]] = (
205
+ data if isinstance(data, list) else data.get("steps", data.get("content", []))
206
+ )
207
+ if steps:
208
+ return steps
209
+ except (NotFoundError, ConnectorError):
210
+ pass
211
+ return []
212
+
213
+ # ------------------------------------------------------------------
214
+ # Mapping helpers
215
+ # ------------------------------------------------------------------
216
+
217
+ @staticmethod
218
+ def _extract_status(raw: Any) -> str | None:
219
+ if isinstance(raw, dict):
220
+ return str(raw["name"]) if "name" in raw else None
221
+ if isinstance(raw, str):
222
+ return raw or None
223
+ return None
224
+
225
+ @staticmethod
226
+ def _extract_tags(raw: Any) -> list[str]:
227
+ if not isinstance(raw, list):
228
+ return []
229
+ result = []
230
+ for item in raw:
231
+ if isinstance(item, dict):
232
+ name = item.get("name")
233
+ if name:
234
+ result.append(str(name))
235
+ elif isinstance(item, str) and item:
236
+ result.append(item)
237
+ return result
238
+
239
+ def _extract_custom_fields(self, raw: Any) -> dict[str, list[str]]:
240
+ """Extract custom fields from the API response.
241
+
242
+ Expected shape (verify against your instance):
243
+ customFields[].customField.name — field name
244
+ customFields[].name — field value
245
+ """
246
+ if not isinstance(raw, list):
247
+ return {}
248
+ result: dict[str, list[str]] = {}
249
+ for item in raw:
250
+ if not isinstance(item, dict):
251
+ continue
252
+ cf = item.get("customField", {})
253
+ field_name: str = cf.get("name", "") if isinstance(cf, dict) else ""
254
+ value: str = item.get("name", "")
255
+ if field_name and value:
256
+ result.setdefault(field_name, []).append(value)
257
+ return result
258
+
259
+ def _extract_suite_path(self, custom_fields: dict[str, list[str]]) -> tuple[str, ...]:
260
+ """Derive suite folder path from the configured custom field.
261
+
262
+ A value of ``"Shipments/Negative"`` becomes ``("Shipments", "Negative")``.
263
+ Cases without the field land in the repo root.
264
+ """
265
+ values = custom_fields.get(self._suite_field, [])
266
+ if not values:
267
+ return ()
268
+ # Use the first value; split on "/" for nested paths
269
+ return tuple(part.strip() for part in values[0].split("/") if part.strip())
270
+
271
+ @staticmethod
272
+ def _map_steps(raw: list[dict[str, Any]]) -> list[Step]:
273
+ result = []
274
+ for item in raw:
275
+ if not isinstance(item, dict):
276
+ continue
277
+ name: str = item.get("name") or item.get("keyword") or ""
278
+ if not name:
279
+ continue
280
+ expected: str | None = item.get("expectedResult") or item.get("expected_result") or None
281
+ nested_raw: list[dict[str, Any]] = item.get("steps", [])
282
+ result.append(
283
+ Step(
284
+ name=name,
285
+ expected_result=expected,
286
+ steps=AllureTestOpsConnector._map_steps(nested_raw),
287
+ )
288
+ )
289
+ return result
290
+
291
+ @staticmethod
292
+ def _map_links(raw: Any) -> list[Link]:
293
+ if not isinstance(raw, list):
294
+ return []
295
+ result = []
296
+ for item in raw:
297
+ if not isinstance(item, dict):
298
+ continue
299
+ url: str = item.get("url", "")
300
+ if not url:
301
+ continue
302
+ result.append(
303
+ Link(
304
+ name=item.get("name") or None,
305
+ url=url,
306
+ type=item.get("type") or None,
307
+ )
308
+ )
309
+ return result
310
+
311
+ def _map_case(
312
+ self,
313
+ detail: dict[str, Any],
314
+ steps_raw: list[dict[str, Any]],
315
+ project_id: str,
316
+ ) -> TestCase:
317
+ case_id = str(detail["id"])
318
+ custom_fields = self._extract_custom_fields(detail.get("customFields"))
319
+ suite_path = self._extract_suite_path(custom_fields)
320
+
321
+ return TestCase(
322
+ id=case_id,
323
+ name=detail.get("name", ""),
324
+ description=detail.get("description") or None,
325
+ precondition=detail.get("precondition") or None,
326
+ expected_result=detail.get("expectedResult") or None,
327
+ status=self._extract_status(detail.get("status")),
328
+ automated=bool(detail.get("automated", False)),
329
+ tags=self._extract_tags(detail.get("tags")),
330
+ custom_fields=custom_fields,
331
+ links=self._map_links(detail.get("links")),
332
+ suite_path=suite_path,
333
+ steps=self._map_steps(steps_raw),
334
+ source_url=f"{self._endpoint}/project/{project_id}/test-cases/{case_id}",
335
+ source_project=project_id,
336
+ )
337
+
338
+ # ------------------------------------------------------------------
339
+ # Public interface
340
+ # ------------------------------------------------------------------
341
+
342
+ def iter_test_cases(self, project_id: str) -> Iterator[TestCase]:
343
+ """Yield all test cases for *project_id*.
344
+
345
+ Fetches the full list page by page, then loads details and steps for
346
+ each case. A ConnectorError on a single case is logged as WARNING and
347
+ that case is skipped; it does not abort the entire sync.
348
+
349
+ Raises AuthError immediately if token exchange fails — this is not
350
+ recoverable per-case.
351
+ """
352
+ page = 0
353
+ while True:
354
+ resp = self._request(
355
+ "GET",
356
+ _PATH_TESTCASE_LIST,
357
+ params={"projectId": project_id, "page": page, "size": _PAGE_SIZE},
358
+ )
359
+ data = resp.json()
360
+ items: list[dict[str, Any]] = data.get("content", [])
361
+ total_pages: int = data.get("totalPages", 1)
362
+
363
+ for item in items:
364
+ case_id = str(item.get("id", ""))
365
+ if not case_id:
366
+ continue
367
+ try:
368
+ detail_resp = self._request("GET", _PATH_TESTCASE_DETAIL.format(id=case_id))
369
+ detail: dict[str, Any] = detail_resp.json()
370
+ steps_raw = self._fetch_steps(case_id)
371
+ yield self._map_case(detail, steps_raw, project_id)
372
+ except AuthError:
373
+ raise
374
+ except Exception as exc:
375
+ logger.warning("Skipping test case %s due to error: %s", case_id, exc)
376
+ continue
377
+
378
+ page += 1
379
+ if page >= total_pages:
380
+ break
@@ -0,0 +1,31 @@
1
+ """Base protocol for TMS connectors.
2
+
3
+ Any new connector must implement :class:`TmsConnector` — a single method
4
+ ``iter_test_cases`` that yields canonical :class:`~testops_mirror.models.TestCase`
5
+ objects. No connector-specific types should leak outside the connector module.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterator
11
+ from typing import Protocol, runtime_checkable
12
+
13
+ from testops_mirror.models import TestCase
14
+
15
+
16
+ @runtime_checkable
17
+ class TmsConnector(Protocol):
18
+ """Structural protocol for TMS connectors.
19
+
20
+ To add a new connector, implement this method and wire it up in the CLI.
21
+ See CONTRIBUTING.md for details.
22
+ """
23
+
24
+ def iter_test_cases(self, project_id: str) -> Iterator[TestCase]:
25
+ """Yield all test cases for *project_id* as canonical TestCase objects.
26
+
27
+ Must raise:
28
+ - :class:`~testops_mirror.exceptions.AuthError` on 401/403
29
+ - :class:`~testops_mirror.exceptions.ConnectorError` on unrecoverable errors
30
+ """
31
+ ...
@@ -0,0 +1,25 @@
1
+ """Custom exception hierarchy for testops-mirror.
2
+
3
+ All exceptions raised by connectors and core modules are subclasses of
4
+ TestopsMirrorError so callers can catch them with a single except clause.
5
+ """
6
+
7
+
8
+ class TestopsMirrorError(Exception):
9
+ """Base exception for all testops-mirror errors."""
10
+
11
+
12
+ class AuthError(TestopsMirrorError):
13
+ """Authentication or authorisation failure (401/403)."""
14
+
15
+
16
+ class NotFoundError(TestopsMirrorError):
17
+ """Requested resource does not exist (404)."""
18
+
19
+
20
+ class RateLimitError(TestopsMirrorError):
21
+ """API rate limit exceeded (429)."""
22
+
23
+
24
+ class ConnectorError(TestopsMirrorError):
25
+ """Generic connector-level error (5xx, network, parse)."""
@@ -0,0 +1,154 @@
1
+ """Git-backed storage for mirrored test cases.
2
+
3
+ All test-case files live under the ``cases/`` subdirectory of the managed
4
+ repository. The store is intentionally TMS-agnostic: it operates on
5
+ (relpath -> content) mappings produced by the serializer.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from dataclasses import dataclass, field
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+
15
+ from git import Actor, InvalidGitRepositoryError, Repo
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ CASES_DIR = "cases"
20
+ DEFAULT_AUTHOR_NAME = "testops-mirror"
21
+ DEFAULT_AUTHOR_EMAIL = "testops-mirror@localhost"
22
+
23
+
24
+ @dataclass
25
+ class ChangeSet:
26
+ added: list[str] = field(default_factory=list)
27
+ updated: list[str] = field(default_factory=list)
28
+ deleted: list[str] = field(default_factory=list)
29
+
30
+ @property
31
+ def empty(self) -> bool:
32
+ return not (self.added or self.updated or self.deleted)
33
+
34
+ def summary(self) -> str:
35
+ parts = []
36
+ if self.added:
37
+ parts.append(f"{len(self.added)} added")
38
+ if self.updated:
39
+ parts.append(f"{len(self.updated)} updated")
40
+ if self.deleted:
41
+ parts.append(f"{len(self.deleted)} deleted")
42
+ return ", ".join(parts) if parts else "no changes"
43
+
44
+
45
+ class GitStore:
46
+ """Manage a ``cases/`` directory inside a Git repository."""
47
+
48
+ def __init__(
49
+ self,
50
+ repo_path: str | Path,
51
+ author_name: str = DEFAULT_AUTHOR_NAME,
52
+ author_email: str = DEFAULT_AUTHOR_EMAIL,
53
+ ) -> None:
54
+ self._root = Path(repo_path)
55
+ self._author = Actor(author_name, author_email)
56
+ self._root.mkdir(parents=True, exist_ok=True)
57
+ try:
58
+ self._repo = Repo(self._root)
59
+ except InvalidGitRepositoryError:
60
+ self._repo = Repo.init(self._root)
61
+ logger.info("Initialised new git repository at %s", self._root)
62
+
63
+ @property
64
+ def cases_dir(self) -> Path:
65
+ return self._root / CASES_DIR
66
+
67
+ # ------------------------------------------------------------------
68
+ # Plan
69
+ # ------------------------------------------------------------------
70
+
71
+ def plan(self, desired: dict[str, str]) -> ChangeSet:
72
+ """Compare *desired* (relpath -> content) against the working tree.
73
+
74
+ Returns a :class:`ChangeSet` describing what needs to change.
75
+ All lists in the result are sorted for deterministic output.
76
+ """
77
+ existing = self._read_existing()
78
+
79
+ desired_keys = set(desired)
80
+ existing_keys = set(existing)
81
+
82
+ added = sorted(desired_keys - existing_keys)
83
+ deleted = sorted(existing_keys - desired_keys)
84
+ updated = sorted(k for k in desired_keys & existing_keys if desired[k] != existing[k])
85
+
86
+ return ChangeSet(added=added, updated=updated, deleted=deleted)
87
+
88
+ # ------------------------------------------------------------------
89
+ # Apply
90
+ # ------------------------------------------------------------------
91
+
92
+ def apply(
93
+ self,
94
+ desired: dict[str, str],
95
+ changes: ChangeSet,
96
+ message: str | None = None,
97
+ ) -> str | None:
98
+ """Write/delete files according to *changes* and create a git commit.
99
+
100
+ Returns the commit SHA, or ``None`` if *changes* is empty.
101
+ """
102
+ if changes.empty:
103
+ return None
104
+
105
+ for relpath in changes.added + changes.updated:
106
+ abs_path = self.cases_dir / relpath
107
+ abs_path.parent.mkdir(parents=True, exist_ok=True)
108
+ abs_path.write_text(desired[relpath], encoding="utf-8")
109
+
110
+ for relpath in changes.deleted:
111
+ abs_path = self.cases_dir / relpath
112
+ if abs_path.exists():
113
+ abs_path.unlink()
114
+
115
+ self._prune_empty_dirs()
116
+
117
+ self._repo.git.add(str(self.cases_dir))
118
+
119
+ if message is None:
120
+ ts = datetime.now(tz=UTC).strftime("%Y-%m-%d %H:%M UTC")
121
+ message = f"sync: {changes.summary()} ({ts})"
122
+
123
+ commit = self._repo.index.commit(
124
+ message,
125
+ author=self._author,
126
+ committer=self._author,
127
+ )
128
+ logger.info("Created commit %s: %s", commit.hexsha[:8], message)
129
+ return str(commit.hexsha)
130
+
131
+ # ------------------------------------------------------------------
132
+ # Internal helpers
133
+ # ------------------------------------------------------------------
134
+
135
+ def _read_existing(self) -> dict[str, str]:
136
+ """Return all *.md files under cases/ as {relpath: content}."""
137
+ if not self.cases_dir.exists():
138
+ return {}
139
+ result: dict[str, str] = {}
140
+ for md_file in self.cases_dir.rglob("*.md"):
141
+ relpath = str(md_file.relative_to(self.cases_dir))
142
+ result[relpath] = md_file.read_text(encoding="utf-8")
143
+ return result
144
+
145
+ def _prune_empty_dirs(self) -> None:
146
+ """Remove empty subdirectories under cases/ (bottom-up)."""
147
+ if not self.cases_dir.exists():
148
+ return
149
+ for dirpath in sorted(self.cases_dir.rglob("*"), reverse=True):
150
+ if dirpath.is_dir() and dirpath != self.cases_dir:
151
+ try:
152
+ dirpath.rmdir()
153
+ except OSError:
154
+ pass
@@ -0,0 +1,45 @@
1
+ """Canonical TMS-agnostic data models.
2
+
3
+ These models are the single source of truth between connectors and the
4
+ serializer. No connector-specific fields belong here.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pydantic import BaseModel
10
+
11
+
12
+ class Step(BaseModel):
13
+ name: str
14
+ expected_result: str | None = None
15
+ steps: list[Step] = []
16
+
17
+
18
+ # Required for Pydantic v2 self-referential model
19
+ Step.model_rebuild()
20
+
21
+
22
+ class Link(BaseModel):
23
+ name: str | None = None
24
+ url: str
25
+ type: str | None = None # "issue" | "tms" | ...
26
+
27
+
28
+ class TestCase(BaseModel):
29
+ # pytest must not collect this as a test class
30
+ __test__ = False
31
+
32
+ id: str
33
+ name: str
34
+ description: str | None = None
35
+ precondition: str | None = None
36
+ expected_result: str | None = None
37
+ status: str | None = None
38
+ automated: bool = False
39
+ tags: list[str] = []
40
+ custom_fields: dict[str, list[str]] = {}
41
+ links: list[Link] = []
42
+ suite_path: tuple[str, ...] = ()
43
+ steps: list[Step] = []
44
+ source_url: str | None = None
45
+ source_project: str | None = None
@@ -0,0 +1,217 @@
1
+ """Serialize TestCase instances to Markdown files with YAML front matter.
2
+
3
+ Invariants enforced here:
4
+ - Determinism: identical input always produces identical bytes.
5
+ - Stable file names: TC-{id}-{slug}.md, slug is cosmetic only.
6
+ - Collision handling: if a different slug exists for the same ID, append -2, -3, ...
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import unicodedata
13
+ from pathlib import PurePosixPath
14
+ from typing import Any
15
+
16
+ import yaml
17
+
18
+ from testops_mirror.models import Step, TestCase
19
+
20
+ # Characters illegal in directory names on Windows/Linux/macOS
21
+ _DIR_FORBIDDEN = re.compile(r'[/\\:*?"<>|]')
22
+
23
+ # Transliteration table for common Cyrillic characters
24
+ _CYRILLIC: dict[str, str] = {
25
+ "а": "a",
26
+ "б": "b",
27
+ "в": "v",
28
+ "г": "g",
29
+ "д": "d",
30
+ "е": "e",
31
+ "ё": "yo",
32
+ "ж": "zh",
33
+ "з": "z",
34
+ "и": "i",
35
+ "й": "j",
36
+ "к": "k",
37
+ "л": "l",
38
+ "м": "m",
39
+ "н": "n",
40
+ "о": "o",
41
+ "п": "p",
42
+ "р": "r",
43
+ "с": "s",
44
+ "т": "t",
45
+ "у": "u",
46
+ "ф": "f",
47
+ "х": "kh",
48
+ "ц": "ts",
49
+ "ч": "ch",
50
+ "ш": "sh",
51
+ "щ": "shch",
52
+ "ъ": "",
53
+ "ы": "y",
54
+ "ь": "",
55
+ "э": "e",
56
+ "ю": "yu",
57
+ "я": "ya",
58
+ }
59
+
60
+
61
+ def slugify(text: str, max_len: int = 60) -> str:
62
+ """Convert arbitrary text to a URL-safe slug.
63
+
64
+ Transliterates Cyrillic, lowercases, strips everything outside [a-z0-9-],
65
+ collapses hyphens, trims to *max_len* characters. Returns ``"case"`` if
66
+ the result would otherwise be empty.
67
+ """
68
+ result = text.lower()
69
+
70
+ # Transliterate Cyrillic
71
+ result = "".join(_CYRILLIC.get(ch, ch) for ch in result)
72
+
73
+ # Decompose accented characters and drop combining marks
74
+ result = unicodedata.normalize("NFKD", result)
75
+ result = "".join(ch for ch in result if not unicodedata.combining(ch))
76
+
77
+ # Replace non-alphanumeric with hyphens
78
+ result = re.sub(r"[^a-z0-9]+", "-", result)
79
+
80
+ # Collapse and strip leading/trailing hyphens
81
+ result = result.strip("-")
82
+
83
+ # Truncate at a word boundary when possible
84
+ if len(result) > max_len:
85
+ truncated = result[:max_len]
86
+ last_hyphen = truncated.rfind("-")
87
+ result = truncated[:last_hyphen] if last_hyphen > 0 else truncated
88
+ result = result.strip("-")
89
+
90
+ return result or "case"
91
+
92
+
93
+ def _clean_dir_name(name: str) -> str:
94
+ """Remove characters that are forbidden in directory names."""
95
+ return _DIR_FORBIDDEN.sub("_", name).strip()
96
+
97
+
98
+ def case_relpath(case: TestCase, existing_paths: set[str] | None = None) -> str:
99
+ """Compute the relative path for *case* inside the ``cases/`` directory.
100
+
101
+ Format: ``{suite_folders}/TC-{id}-{slug}.md``
102
+
103
+ Collision handling: if *existing_paths* already contains a path with the
104
+ same ``TC-{id}-`` prefix but a different slug, append ``-2``, ``-3``, ...
105
+ until the name is unique.
106
+ """
107
+ slug = slugify(case.name)
108
+ dir_parts = [_clean_dir_name(part) for part in case.suite_path if part]
109
+ base_stem = f"TC-{case.id}-{slug}"
110
+
111
+ def _build(stem: str) -> str:
112
+ filename = f"{stem}.md"
113
+ parts = [*dir_parts, filename]
114
+ return str(PurePosixPath(*parts)) if parts else filename
115
+
116
+ candidate = _build(base_stem)
117
+ if existing_paths is None:
118
+ return candidate
119
+
120
+ # Check for existing file with same ID but different slug
121
+ prefix = f"TC-{case.id}-"
122
+ conflicting = {
123
+ p for p in existing_paths if p != candidate and PurePosixPath(p).stem.startswith(prefix)
124
+ }
125
+ if not conflicting:
126
+ return candidate
127
+
128
+ # Generate unique suffix
129
+ counter = 2
130
+ while True:
131
+ stem = f"{base_stem}-{counter}"
132
+ candidate = _build(stem)
133
+ if candidate not in existing_paths:
134
+ return candidate
135
+ counter += 1
136
+
137
+
138
+ def _render_steps(steps: list[Step], indent: int = 0) -> list[str]:
139
+ """Render a (possibly nested) step list as numbered Markdown lines."""
140
+ lines: list[str] = []
141
+ prefix = " " * indent
142
+ for i, step in enumerate(steps, 1):
143
+ lines.append(f"{prefix}{i}. {step.name}")
144
+ if step.expected_result:
145
+ lines.append(f"{prefix} - **Expected:** {step.expected_result}")
146
+ if step.steps:
147
+ lines.extend(_render_steps(step.steps, indent + 1))
148
+ return lines
149
+
150
+
151
+ def serialize(case: TestCase) -> str:
152
+ """Render *case* as a Markdown string with YAML front matter.
153
+
154
+ The output is deterministic: same input → same bytes every time.
155
+ """
156
+ # --- Build front matter dict (fixed key order, skip None/empty) ---
157
+ fm: dict[str, Any] = {}
158
+ fm["id"] = case.id
159
+ fm["name"] = case.name
160
+
161
+ if case.status is not None:
162
+ fm["status"] = case.status
163
+
164
+ fm["automated"] = case.automated
165
+
166
+ if case.tags:
167
+ fm["tags"] = sorted(case.tags)
168
+
169
+ if case.custom_fields:
170
+ fm["custom_fields"] = {k: sorted(v) for k, v in sorted(case.custom_fields.items())}
171
+
172
+ if case.links:
173
+ fm["links"] = [
174
+ {
175
+ k: v
176
+ for k, v in {"name": lnk.name, "url": lnk.url, "type": lnk.type}.items()
177
+ if v is not None
178
+ }
179
+ for lnk in case.links
180
+ ]
181
+
182
+ if case.source_url or case.source_project:
183
+ source: dict[str, str] = {}
184
+ if case.source_url:
185
+ source["url"] = case.source_url
186
+ if case.source_project:
187
+ source["project"] = case.source_project
188
+ fm["source"] = source
189
+
190
+ front_matter = yaml.safe_dump(
191
+ fm,
192
+ sort_keys=False,
193
+ allow_unicode=True,
194
+ default_flow_style=False,
195
+ ).rstrip("\n")
196
+
197
+ # --- Build Markdown body ---
198
+ body_lines: list[str] = [f"# {case.name}", ""]
199
+
200
+ if case.description:
201
+ body_lines += [case.description, ""]
202
+
203
+ if case.precondition:
204
+ body_lines += ["## Preconditions", "", case.precondition, ""]
205
+
206
+ if case.steps:
207
+ body_lines.append("## Steps")
208
+ body_lines.append("")
209
+ body_lines.extend(_render_steps(case.steps))
210
+ body_lines.append("")
211
+
212
+ if case.expected_result:
213
+ body_lines += ["## Expected result", "", case.expected_result, ""]
214
+
215
+ body = "\n".join(body_lines).rstrip("\n") + "\n"
216
+
217
+ return f"---\n{front_matter}\n---\n\n{body}"
testops_mirror/sync.py ADDED
@@ -0,0 +1,64 @@
1
+ """Orchestrator: connector -> serialize -> plan -> commit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Callable
7
+
8
+ from testops_mirror.connectors.base import TmsConnector
9
+ from testops_mirror.gitstore import ChangeSet, GitStore
10
+ from testops_mirror.models import TestCase
11
+ from testops_mirror.serializer import case_relpath, serialize
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def run_sync(
17
+ connector: TmsConnector,
18
+ store: GitStore,
19
+ project_id: str,
20
+ *,
21
+ dry_run: bool = False,
22
+ on_case: Callable[[TestCase], None] | None = None,
23
+ ) -> tuple[ChangeSet, str | None]:
24
+ """Pull all test cases and mirror them into the git store.
25
+
26
+ Parameters
27
+ ----------
28
+ connector:
29
+ Any object implementing the TmsConnector protocol.
30
+ store:
31
+ Initialised GitStore pointing at the target repository.
32
+ project_id:
33
+ TMS project identifier passed to the connector.
34
+ dry_run:
35
+ If True, compute the plan but do not write files or create commits.
36
+ on_case:
37
+ Optional callback invoked for each fetched TestCase (used by CLI for
38
+ progress reporting).
39
+
40
+ Returns
41
+ -------
42
+ (ChangeSet, sha | None)
43
+ The planned change set and the commit SHA (None when dry_run or no changes).
44
+ """
45
+ desired: dict[str, str] = {}
46
+ seen_paths: set[str] = set()
47
+
48
+ for case in connector.iter_test_cases(project_id):
49
+ if on_case is not None:
50
+ on_case(case)
51
+ relpath = case_relpath(case, existing_paths=seen_paths)
52
+ seen_paths.add(relpath)
53
+ desired[relpath] = serialize(case)
54
+ logger.debug("Serialized %s -> %s", case.id, relpath)
55
+
56
+ logger.info("Fetched %d test cases", len(desired))
57
+
58
+ changes = store.plan(desired)
59
+
60
+ if dry_run or changes.empty:
61
+ return changes, None
62
+
63
+ sha = store.apply(desired, changes)
64
+ return changes, sha
@@ -0,0 +1,164 @@
1
+ Metadata-Version: 2.4
2
+ Name: testops-mirror
3
+ Version: 0.1.0
4
+ Summary: Mirror test cases from any TMS into a Git repository as Markdown files
5
+ Project-URL: Homepage, https://github.com/exzist-qa/testops-mirror
6
+ Project-URL: Repository, https://github.com/exzist-qa/testops-mirror
7
+ Project-URL: Issues, https://github.com/exzist-qa/testops-mirror/issues
8
+ Author: testops-mirror contributors
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 testops-mirror contributors
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: allure,git,mirror,test-management,testing,testops,tms
32
+ Classifier: Development Status :: 3 - Alpha
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Operating System :: OS Independent
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3.11
38
+ Classifier: Programming Language :: Python :: 3.12
39
+ Classifier: Topic :: Software Development :: Testing
40
+ Requires-Python: >=3.11
41
+ Requires-Dist: gitpython>=3.1
42
+ Requires-Dist: httpx>=0.27
43
+ Requires-Dist: pydantic>=2
44
+ Requires-Dist: python-dotenv>=1.0
45
+ Requires-Dist: pyyaml>=6
46
+ Requires-Dist: rich>=13
47
+ Requires-Dist: typer>=0.12
48
+ Provides-Extra: dev
49
+ Requires-Dist: mypy>=1.10; extra == 'dev'
50
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
51
+ Requires-Dist: pytest>=8; extra == 'dev'
52
+ Requires-Dist: ruff>=0.4; extra == 'dev'
53
+ Requires-Dist: types-pyyaml; extra == 'dev'
54
+ Description-Content-Type: text/markdown
55
+
56
+ # testops-mirror
57
+
58
+ > Mirror test cases from any TMS into a Git repository — one file per test case, history forever.
59
+
60
+ ```
61
+ mirror/
62
+ ├── Transfers/
63
+ │ ├── Negative/
64
+ │ │ └── TC-2301-transfer-to-blocked-account.md
65
+ │ └── TC-2201-transfer-between-own-accounts.md
66
+ └── Auth/
67
+ └── TC-1001-login-with-valid-credentials.md
68
+ ```
69
+
70
+ ## Why
71
+
72
+ Your TMS is a single point of failure. Test cases deserve version control just like code —
73
+ history, diff, blame, and code review through merge requests.
74
+
75
+ testops-mirror pulls every test case on a schedule and commits changes to a plain Git repo.
76
+ No vendor lock-in, no proprietary format.
77
+
78
+ ## Quick Start
79
+
80
+ **pip:**
81
+
82
+ ```bash
83
+ pip install testops-mirror
84
+ cp .env.example .env # fill in TESTOPS_ENDPOINT and TESTOPS_TOKEN
85
+ testops-mirror sync --project-id 42 --repo ./mirror
86
+ ```
87
+
88
+ **Docker:**
89
+
90
+ ```bash
91
+ docker run --rm \
92
+ -e TESTOPS_ENDPOINT=https://testops.example.com \
93
+ -e TESTOPS_TOKEN=your-token \
94
+ -v $(pwd)/mirror:/mirror \
95
+ ghcr.io/exzist-qa/testops-mirror \
96
+ sync --project-id 42 --repo /mirror
97
+ ```
98
+
99
+ ## Example output file
100
+
101
+ ```markdown
102
+ ---
103
+ id: '2301'
104
+ name: Transfer to blocked account
105
+ status: Ready
106
+ automated: false
107
+ tags: [api, negative]
108
+ links:
109
+ - {name: BAN-17, url: 'https://jira.example.com/browse/BAN-17', type: issue}
110
+ source: {url: 'https://testops.example.com/project/42/test-cases/2301', project: '42'}
111
+ ---
112
+
113
+ # Transfer to blocked account
114
+
115
+ ## Preconditions
116
+
117
+ Sender account has sufficient balance. Recipient account status is BLOCKED.
118
+
119
+ ## Steps
120
+
121
+ 1. Send `POST /transfers`
122
+ - **Expected:** `422 Unprocessable Entity`
123
+ 2. Check response body
124
+ 1. Field `code` equals `ACCOUNT_BLOCKED`
125
+
126
+ ## Expected result
127
+
128
+ API returns 422 with error code ACCOUNT_BLOCKED.
129
+ ```
130
+
131
+ ## Supported TMS
132
+
133
+ | TMS | Status |
134
+ |-----|--------|
135
+ | Allure TestOps | ✅ |
136
+ | TestIT | 🚧 planned |
137
+ | TestRail | 🤝 contributions welcome |
138
+
139
+ ## Configuration
140
+
141
+ | Option | Env | Default | Description |
142
+ |--------|-----|---------|-------------|
143
+ | `--project-id` | — | required | TMS project ID |
144
+ | `--endpoint` | `TESTOPS_ENDPOINT` | required | Base URL of TMS |
145
+ | `--token` | `TESTOPS_TOKEN` | required | API token |
146
+ | `--repo` | — | `./mirror` | Local Git repo path |
147
+ | `--suite-field` | `TESTOPS_SUITE_FIELD` | `Suite` | Custom field for folder structure |
148
+ | `--dry-run` | — | false | Preview changes without writing |
149
+ | `-v/--verbose` | — | false | Verbose logging |
150
+
151
+ > **Note:** API endpoint paths are verified against Allure TestOps documented API.
152
+ > If your instance uses different paths, check `<endpoint>/swagger-ui/` and
153
+ > update the constants in `connectors/allure_testops.py`.
154
+
155
+ ## Roadmap
156
+
157
+ - Attachments download
158
+ - TestIT connector
159
+ - Index file (`cases/INDEX.md`) with a table of all cases
160
+ - Reverse import (Markdown → TMS)
161
+
162
+ ## License
163
+
164
+ MIT
@@ -0,0 +1,15 @@
1
+ testops_mirror/__init__.py,sha256=uKrAWK61H5tHGo_1Kn1BTKo40j_S6DR1rPFf5F8_TP0,90
2
+ testops_mirror/cli.py,sha256=kenD3lFD9KwI8R-dfaU3HKm7rDFfiT_SUJutOJVqsLE,4922
3
+ testops_mirror/exceptions.py,sha256=25D1RFhJChQ5ZnRRcY2R_3Oy-FBAK3AzlKmZcjU5vwY,681
4
+ testops_mirror/gitstore.py,sha256=NcJpPH7Hkygfij7LfxhaHZgt-XKDJ3uIhQMDn9Wu4ng,5213
5
+ testops_mirror/models.py,sha256=FMfPGFKafT_JTFVhmMWaD0c039FDs1eHb7obqyua4bg,1063
6
+ testops_mirror/serializer.py,sha256=H5Kpy_8U3RjmCOmi5XLWOcesFiJiJKt1svaHI_ErzaU,6197
7
+ testops_mirror/sync.py,sha256=5VV-K6NGm6ydHYT46Mdz9unqGJkqDFFXAYMlSgUW0qI,1887
8
+ testops_mirror/connectors/__init__.py,sha256=6-ozPiilbQFvpWdLLoh_doVNAE9QMRTxK_9EII46IH8,37
9
+ testops_mirror/connectors/allure_testops.py,sha256=iDYOGPi0CQSEufEk7Y4mVlQYjENt2vtLOhutZjXklEI,14535
10
+ testops_mirror/connectors/base.py,sha256=FXfMaFzJxua1MVFgPpt4RkJHYjCf8K5MpuqcnctsHD0,1014
11
+ testops_mirror-0.1.0.dist-info/METADATA,sha256=Mr_-nOAxRjvaZEizgm0xolSvOpkghvcqF7CiCJrXBf4,5421
12
+ testops_mirror-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
13
+ testops_mirror-0.1.0.dist-info/entry_points.txt,sha256=JiPErL91CBJpsPSLZBHehfZ82pCpEPjXrvwagkVd3UA,58
14
+ testops_mirror-0.1.0.dist-info/licenses/LICENSE,sha256=NzNVXU-PP5CoKV09KwYEV7ZEpUpbeGq159cVj6udtcQ,1084
15
+ testops_mirror-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ testops-mirror = testops_mirror.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 testops-mirror contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.