noswag-cli 0.1.0__tar.gz
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.
- noswag_cli-0.1.0/.gitignore +5 -0
- noswag_cli-0.1.0/PKG-INFO +58 -0
- noswag_cli-0.1.0/README.md +36 -0
- noswag_cli-0.1.0/pyproject.toml +47 -0
- noswag_cli-0.1.0/src/noswag/__init__.py +3 -0
- noswag_cli-0.1.0/src/noswag/__main__.py +3 -0
- noswag_cli-0.1.0/src/noswag/api.py +134 -0
- noswag_cli-0.1.0/src/noswag/cli.py +312 -0
- noswag_cli-0.1.0/src/noswag/config.py +141 -0
- noswag_cli-0.1.0/src/noswag/credentials.py +90 -0
- noswag_cli-0.1.0/src/noswag/errors.py +36 -0
- noswag_cli-0.1.0/src/noswag/generated/__init__.py +1 -0
- noswag_cli-0.1.0/src/noswag/generated/public_v1.py +198 -0
- noswag_cli-0.1.0/src/noswag/repository.py +293 -0
- noswag_cli-0.1.0/src/noswag/verification.py +67 -0
- noswag_cli-0.1.0/src/noswag/writer.py +105 -0
- noswag_cli-0.1.0/tests/test_config.py +53 -0
- noswag_cli-0.1.0/tests/test_repository.py +78 -0
- noswag_cli-0.1.0/tests/test_writer.py +74 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: noswag-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Generate repository-native tests with NoSwag
|
|
5
|
+
Project-URL: Homepage, https://noswag.io
|
|
6
|
+
Project-URL: Repository, https://github.com/noswag/noswag
|
|
7
|
+
Author: NoSwag
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Requires-Dist: keyring<27,>=25
|
|
16
|
+
Requires-Dist: pyyaml<7,>=6
|
|
17
|
+
Requires-Dist: typing-extensions<5,>=4.6
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: mypy<3,>=1.10; extra == 'dev'
|
|
20
|
+
Requires-Dist: types-pyyaml<7,>=6; extra == 'dev'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# NoSwag Python CLI
|
|
24
|
+
|
|
25
|
+
The `noswag` package authenticates through the NoSwag device flow, packages a
|
|
26
|
+
bounded snapshot of the current Git repository, requests repository-native
|
|
27
|
+
tests, and previews or safely writes the returned artifact.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
python -m pip install noswag-cli
|
|
31
|
+
noswag auth login
|
|
32
|
+
noswag init
|
|
33
|
+
noswag gentest # repository-aware dry-run
|
|
34
|
+
noswag gentest --spec openapi.yaml # contract tests from a spec
|
|
35
|
+
noswag gentest --write # guarded write
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`--spec` accepts a repository-relative JSON or YAML OpenAPI/Swagger file and
|
|
39
|
+
includes it even when the Git diff has no source changes. Generated contract
|
|
40
|
+
checks use `NOSWAG_BASE_URL` at execution time and optionally
|
|
41
|
+
`NOSWAG_API_TOKEN`, `NOSWAG_API_KEY`, `NOSWAG_PATH_*`, and `NOSWAG_QUERY_*`.
|
|
42
|
+
|
|
43
|
+
Tokens are stored through the operating system credential store. Set
|
|
44
|
+
`NOSWAG_API_URL` for staging/local use. CI can use a platform API key via
|
|
45
|
+
`NOSWAG_API_KEY` (or the backwards-compatible `NOSWAG_TOKEN`) without writing
|
|
46
|
+
a credential file. Platform keys carry their workspace, so
|
|
47
|
+
`NOSWAG_WORKSPACE_ID` is optional. The key can also be passed with
|
|
48
|
+
`--api-key`.
|
|
49
|
+
Interactive login stores a rotating refresh token in the OS credential store;
|
|
50
|
+
the short-lived access token is refreshed automatically and logout revokes the
|
|
51
|
+
refresh family.
|
|
52
|
+
|
|
53
|
+
Repository verification commands run only when `verify.enabled: true` is
|
|
54
|
+
committed in `.noswag.yml` or `--verify` is supplied, and only together with
|
|
55
|
+
`--write`. No detected command is executed implicitly.
|
|
56
|
+
|
|
57
|
+
Exit codes: `2` authentication, `3` quota/license, `4` generation, `5`
|
|
58
|
+
validation, and `6` write conflict.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# NoSwag Python CLI
|
|
2
|
+
|
|
3
|
+
The `noswag` package authenticates through the NoSwag device flow, packages a
|
|
4
|
+
bounded snapshot of the current Git repository, requests repository-native
|
|
5
|
+
tests, and previews or safely writes the returned artifact.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
python -m pip install noswag-cli
|
|
9
|
+
noswag auth login
|
|
10
|
+
noswag init
|
|
11
|
+
noswag gentest # repository-aware dry-run
|
|
12
|
+
noswag gentest --spec openapi.yaml # contract tests from a spec
|
|
13
|
+
noswag gentest --write # guarded write
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`--spec` accepts a repository-relative JSON or YAML OpenAPI/Swagger file and
|
|
17
|
+
includes it even when the Git diff has no source changes. Generated contract
|
|
18
|
+
checks use `NOSWAG_BASE_URL` at execution time and optionally
|
|
19
|
+
`NOSWAG_API_TOKEN`, `NOSWAG_API_KEY`, `NOSWAG_PATH_*`, and `NOSWAG_QUERY_*`.
|
|
20
|
+
|
|
21
|
+
Tokens are stored through the operating system credential store. Set
|
|
22
|
+
`NOSWAG_API_URL` for staging/local use. CI can use a platform API key via
|
|
23
|
+
`NOSWAG_API_KEY` (or the backwards-compatible `NOSWAG_TOKEN`) without writing
|
|
24
|
+
a credential file. Platform keys carry their workspace, so
|
|
25
|
+
`NOSWAG_WORKSPACE_ID` is optional. The key can also be passed with
|
|
26
|
+
`--api-key`.
|
|
27
|
+
Interactive login stores a rotating refresh token in the OS credential store;
|
|
28
|
+
the short-lived access token is refreshed automatically and logout revokes the
|
|
29
|
+
refresh family.
|
|
30
|
+
|
|
31
|
+
Repository verification commands run only when `verify.enabled: true` is
|
|
32
|
+
committed in `.noswag.yml` or `--verify` is supplied, and only together with
|
|
33
|
+
`--write`. No detected command is executed implicitly.
|
|
34
|
+
|
|
35
|
+
Exit codes: `2` authentication, `3` quota/license, `4` generation, `5`
|
|
36
|
+
validation, and `6` write conflict.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "noswag-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Generate repository-native tests with NoSwag"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "NoSwag" }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"keyring>=25,<27",
|
|
15
|
+
"PyYAML>=6,<7",
|
|
16
|
+
"typing-extensions>=4.6,<5",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = [
|
|
28
|
+
"mypy>=1.10,<3",
|
|
29
|
+
"types-PyYAML>=6,<7",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
noswag = "noswag.cli:main"
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://noswag.io"
|
|
37
|
+
Repository = "https://github.com/noswag/noswag"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["src/noswag"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
|
|
45
|
+
[tool.mypy]
|
|
46
|
+
python_version = "3.10"
|
|
47
|
+
strict = true
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.error
|
|
5
|
+
import urllib.parse
|
|
6
|
+
import urllib.request
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .errors import ApiError, ValidationError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NoSwagClient:
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
base_url: str,
|
|
16
|
+
token: str | None = None,
|
|
17
|
+
workspace_id: str | None = None,
|
|
18
|
+
timeout: float = 30,
|
|
19
|
+
) -> None:
|
|
20
|
+
if not base_url.startswith(("http://", "https://")):
|
|
21
|
+
raise ValidationError("API URL must use HTTP or HTTPS")
|
|
22
|
+
self.base_url = base_url.rstrip("/")
|
|
23
|
+
self.token = token
|
|
24
|
+
self.workspace_id = workspace_id
|
|
25
|
+
self.timeout = timeout
|
|
26
|
+
|
|
27
|
+
def request(
|
|
28
|
+
self,
|
|
29
|
+
method: str,
|
|
30
|
+
path: str,
|
|
31
|
+
body: dict[str, Any] | None = None,
|
|
32
|
+
*,
|
|
33
|
+
idempotency_key: str | None = None,
|
|
34
|
+
authenticated: bool = True,
|
|
35
|
+
) -> dict[str, Any]:
|
|
36
|
+
headers = {
|
|
37
|
+
"Accept": "application/json",
|
|
38
|
+
"User-Agent": "noswag-python/0.1.0",
|
|
39
|
+
}
|
|
40
|
+
if body is not None:
|
|
41
|
+
headers["Content-Type"] = "application/json"
|
|
42
|
+
if authenticated:
|
|
43
|
+
if not self.token:
|
|
44
|
+
raise ValidationError("No authentication token is configured")
|
|
45
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
46
|
+
if self.workspace_id:
|
|
47
|
+
headers["X-Workspace-Id"] = self.workspace_id
|
|
48
|
+
if idempotency_key:
|
|
49
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
50
|
+
request = urllib.request.Request(
|
|
51
|
+
f"{self.base_url}{path}",
|
|
52
|
+
data=(
|
|
53
|
+
json.dumps(body, separators=(",", ":")).encode()
|
|
54
|
+
if body is not None
|
|
55
|
+
else None
|
|
56
|
+
),
|
|
57
|
+
headers=headers,
|
|
58
|
+
method=method,
|
|
59
|
+
)
|
|
60
|
+
try:
|
|
61
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
62
|
+
payload = json.loads(response.read().decode())
|
|
63
|
+
except urllib.error.HTTPError as error:
|
|
64
|
+
try:
|
|
65
|
+
payload = json.loads(error.read().decode())
|
|
66
|
+
detail = payload.get("error", {})
|
|
67
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
68
|
+
detail = {}
|
|
69
|
+
raise ApiError(
|
|
70
|
+
error.code,
|
|
71
|
+
str(detail.get("code", "http_error")),
|
|
72
|
+
str(detail.get("message", f"NoSwag API returned HTTP {error.code}")),
|
|
73
|
+
) from None
|
|
74
|
+
except urllib.error.URLError as error:
|
|
75
|
+
raise ApiError(0, "network_error", f"Could not reach NoSwag API: {error.reason}") from None
|
|
76
|
+
if not isinstance(payload, dict) or payload.get("success") is not True:
|
|
77
|
+
raise ApiError(0, "invalid_response", "NoSwag API returned an invalid response")
|
|
78
|
+
data = payload.get("data")
|
|
79
|
+
if not isinstance(data, dict):
|
|
80
|
+
raise ApiError(0, "invalid_response", "NoSwag API response has no data object")
|
|
81
|
+
return data
|
|
82
|
+
|
|
83
|
+
def issue_device_code(self) -> dict[str, Any]:
|
|
84
|
+
return self.request(
|
|
85
|
+
"POST",
|
|
86
|
+
"/v1/auth/device/code",
|
|
87
|
+
{
|
|
88
|
+
"client_name": "NoSwag Python CLI",
|
|
89
|
+
"scopes": [
|
|
90
|
+
"repository:read",
|
|
91
|
+
"repository:write",
|
|
92
|
+
"generation:create",
|
|
93
|
+
"generation:read",
|
|
94
|
+
"usage:read",
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
authenticated=False,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def exchange_device_code(self, device_code: str) -> dict[str, Any]:
|
|
101
|
+
return self.request(
|
|
102
|
+
"POST",
|
|
103
|
+
"/v1/auth/device/token",
|
|
104
|
+
{"device_code": device_code},
|
|
105
|
+
authenticated=False,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def refresh_access_token(self, refresh_token: str) -> dict[str, Any]:
|
|
109
|
+
return self.request(
|
|
110
|
+
"POST",
|
|
111
|
+
"/v1/auth/token/refresh",
|
|
112
|
+
{"refresh_token": refresh_token},
|
|
113
|
+
authenticated=False,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def revoke_refresh_token(self, refresh_token: str) -> dict[str, Any]:
|
|
117
|
+
return self.request(
|
|
118
|
+
"POST",
|
|
119
|
+
"/v1/auth/token/revoke",
|
|
120
|
+
{"refresh_token": refresh_token},
|
|
121
|
+
authenticated=False,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def get(self, path: str) -> dict[str, Any]:
|
|
125
|
+
return self.request("GET", path)
|
|
126
|
+
|
|
127
|
+
def post(
|
|
128
|
+
self,
|
|
129
|
+
path: str,
|
|
130
|
+
body: dict[str, Any],
|
|
131
|
+
*,
|
|
132
|
+
idempotency_key: str | None = None,
|
|
133
|
+
) -> dict[str, Any]:
|
|
134
|
+
return self.request("POST", path, body, idempotency_key=idempotency_key)
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
import webbrowser
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .api import NoSwagClient
|
|
14
|
+
from .config import DEFAULT_CONFIG, load_config
|
|
15
|
+
from .credentials import Credential, KeyringCredentialStore
|
|
16
|
+
from .errors import ApiError, GenerationError, NoSwagError, ValidationError
|
|
17
|
+
from .repository import (
|
|
18
|
+
build_context,
|
|
19
|
+
config_hash,
|
|
20
|
+
find_git_root,
|
|
21
|
+
idempotency_key,
|
|
22
|
+
repository_identity,
|
|
23
|
+
)
|
|
24
|
+
from .writer import apply_artifact
|
|
25
|
+
from .verification import plan_verification, run_verification
|
|
26
|
+
|
|
27
|
+
DEFAULT_API_URL = "https://api.noswag.io"
|
|
28
|
+
TERMINAL = {"completed", "failed", "cancelled", "timed_out"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _api_url(args: argparse.Namespace) -> str:
|
|
32
|
+
return str(args.api_url or os.environ.get("NOSWAG_API_URL", DEFAULT_API_URL)).rstrip("/")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _client(args: argparse.Namespace) -> NoSwagClient:
|
|
36
|
+
url = _api_url(args)
|
|
37
|
+
configured_token = (
|
|
38
|
+
args.api_key
|
|
39
|
+
or os.environ.get("NOSWAG_API_KEY")
|
|
40
|
+
or os.environ.get("NOSWAG_TOKEN")
|
|
41
|
+
)
|
|
42
|
+
store = KeyringCredentialStore()
|
|
43
|
+
credential = None if configured_token else store.load(url)
|
|
44
|
+
if (
|
|
45
|
+
credential
|
|
46
|
+
and credential.refresh_token
|
|
47
|
+
and credential.expires_at
|
|
48
|
+
and credential.expires_at <= time.time() + 30
|
|
49
|
+
and not configured_token
|
|
50
|
+
):
|
|
51
|
+
result = NoSwagClient(url).refresh_access_token(
|
|
52
|
+
credential.refresh_token
|
|
53
|
+
)
|
|
54
|
+
credential = Credential(
|
|
55
|
+
token=str(result["access_token"]),
|
|
56
|
+
workspace_id=str(result["workspace_id"]),
|
|
57
|
+
refresh_token=str(result["refresh_token"]),
|
|
58
|
+
expires_at=time.time() + int(result["expires_in"]),
|
|
59
|
+
)
|
|
60
|
+
store.save(url, credential)
|
|
61
|
+
token = configured_token or (credential.token if credential else None)
|
|
62
|
+
workspace = os.environ.get("NOSWAG_WORKSPACE_ID") or (
|
|
63
|
+
credential.workspace_id if credential else None
|
|
64
|
+
)
|
|
65
|
+
if not token or (not workspace and not token.startswith("nsw_")):
|
|
66
|
+
raise ValidationError("Not authenticated; run `noswag auth login` or set NOSWAG_API_KEY (platform keys do not need NOSWAG_WORKSPACE_ID)")
|
|
67
|
+
return NoSwagClient(url, token, workspace)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _emit(value: Any, json_output: bool) -> None:
|
|
71
|
+
if json_output:
|
|
72
|
+
print(json.dumps(value, separators=(",", ":"), sort_keys=True))
|
|
73
|
+
elif isinstance(value, str):
|
|
74
|
+
print(value)
|
|
75
|
+
else:
|
|
76
|
+
print(json.dumps(value, indent=2, sort_keys=True))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def auth_login(args: argparse.Namespace) -> dict[str, Any]:
|
|
80
|
+
url = _api_url(args)
|
|
81
|
+
client = NoSwagClient(url)
|
|
82
|
+
issued = client.issue_device_code()
|
|
83
|
+
print(f"Enter code {issued['user_code']} at {issued['verification_uri']}", file=sys.stderr)
|
|
84
|
+
if not args.no_browser:
|
|
85
|
+
webbrowser.open(str(issued.get("verification_uri_complete") or issued["verification_uri"]))
|
|
86
|
+
deadline = time.monotonic() + int(issued["expires_in"])
|
|
87
|
+
interval = max(1, int(issued["interval"]))
|
|
88
|
+
while time.monotonic() < deadline:
|
|
89
|
+
time.sleep(interval)
|
|
90
|
+
try:
|
|
91
|
+
result = client.exchange_device_code(str(issued["device_code"]))
|
|
92
|
+
except ApiError as error:
|
|
93
|
+
if error.code == "authorization_pending":
|
|
94
|
+
continue
|
|
95
|
+
if error.code == "slow_down":
|
|
96
|
+
interval += 5
|
|
97
|
+
continue
|
|
98
|
+
raise
|
|
99
|
+
credential = Credential(
|
|
100
|
+
token=str(result["access_token"]),
|
|
101
|
+
workspace_id=str(result["workspace_id"]),
|
|
102
|
+
refresh_token=str(result["refresh_token"]),
|
|
103
|
+
expires_at=time.time() + int(result["expires_in"]),
|
|
104
|
+
)
|
|
105
|
+
KeyringCredentialStore().save(url, credential)
|
|
106
|
+
return {"authenticated": True, "workspace_id": credential.workspace_id}
|
|
107
|
+
raise ValidationError("Device authorization expired")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def auth_status(args: argparse.Namespace) -> dict[str, Any]:
|
|
111
|
+
return _client(args).get("/v1/me")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def auth_logout(args: argparse.Namespace) -> dict[str, Any]:
|
|
115
|
+
url = _api_url(args)
|
|
116
|
+
store = KeyringCredentialStore()
|
|
117
|
+
credential = store.load(url)
|
|
118
|
+
if credential and credential.refresh_token:
|
|
119
|
+
try:
|
|
120
|
+
NoSwagClient(url).revoke_refresh_token(
|
|
121
|
+
credential.refresh_token
|
|
122
|
+
)
|
|
123
|
+
except ApiError:
|
|
124
|
+
pass
|
|
125
|
+
store.delete(url)
|
|
126
|
+
return {"authenticated": False}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def init_repo(args: argparse.Namespace) -> dict[str, Any]:
|
|
130
|
+
root = find_git_root()
|
|
131
|
+
target = root / ".noswag.yml"
|
|
132
|
+
if target.exists() and not args.force:
|
|
133
|
+
raise ValidationError(".noswag.yml already exists; use --force to replace it")
|
|
134
|
+
target.write_text(DEFAULT_CONFIG)
|
|
135
|
+
return {"repository_root": str(root), "config": str(target)}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def doctor(args: argparse.Namespace) -> dict[str, Any]:
|
|
139
|
+
root = find_git_root()
|
|
140
|
+
config = load_config(root, args.config)
|
|
141
|
+
client = _client(args)
|
|
142
|
+
me = client.get("/v1/me")
|
|
143
|
+
context = build_context(root, config, args.base, args.head)
|
|
144
|
+
return {
|
|
145
|
+
"ok": True,
|
|
146
|
+
"repository_root": str(root),
|
|
147
|
+
"workspace_id": client.workspace_id,
|
|
148
|
+
"principal": me.get("principal"),
|
|
149
|
+
"context_files": len(context.files),
|
|
150
|
+
"changed_files": len(context.changed_files),
|
|
151
|
+
"head_sha": context.head_sha,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _repository(client: NoSwagClient, root: Path, config: Any) -> dict[str, Any]:
|
|
156
|
+
owner, name = repository_identity(root)
|
|
157
|
+
listed = client.get("/v1/repositories").get("repositories", [])
|
|
158
|
+
if not isinstance(listed, list):
|
|
159
|
+
raise ValidationError("Repository list response is invalid")
|
|
160
|
+
for value in listed:
|
|
161
|
+
if not isinstance(value, dict):
|
|
162
|
+
continue
|
|
163
|
+
repository = cast(dict[str, Any], value)
|
|
164
|
+
if repository.get("owner") == owner and repository.get("name") == name:
|
|
165
|
+
return repository
|
|
166
|
+
created = client.post(
|
|
167
|
+
"/v1/repositories",
|
|
168
|
+
{
|
|
169
|
+
"provider": "local",
|
|
170
|
+
"owner": owner,
|
|
171
|
+
"name": name,
|
|
172
|
+
"default_branch": "main",
|
|
173
|
+
"policy": {
|
|
174
|
+
"mode": config.scope,
|
|
175
|
+
"languages": list(config.languages),
|
|
176
|
+
"frameworks": list(config.frameworks),
|
|
177
|
+
"max_files": config.limits.max_generated_files,
|
|
178
|
+
"allow_new_dependencies": config.allow_new_dependencies,
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
)
|
|
182
|
+
created_repository = created.get("repository")
|
|
183
|
+
if not isinstance(created_repository, dict):
|
|
184
|
+
raise ValidationError("Repository response is invalid")
|
|
185
|
+
return created_repository
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def gentest(args: argparse.Namespace) -> dict[str, Any]:
|
|
189
|
+
root = find_git_root()
|
|
190
|
+
config = load_config(root, args.config)
|
|
191
|
+
verification_commands = plan_verification(
|
|
192
|
+
configured=config.verify_enabled,
|
|
193
|
+
requested=bool(args.verify),
|
|
194
|
+
write=bool(args.write),
|
|
195
|
+
commands=config.verify_commands,
|
|
196
|
+
)
|
|
197
|
+
context = build_context(root, config, args.base, args.head, args.spec)
|
|
198
|
+
client = _client(args)
|
|
199
|
+
repository = _repository(client, root, config)
|
|
200
|
+
write = bool(args.write)
|
|
201
|
+
key = idempotency_key(root, context, config, "py")
|
|
202
|
+
created = client.post(
|
|
203
|
+
"/v1/generations",
|
|
204
|
+
{
|
|
205
|
+
"repository_id": repository["id"],
|
|
206
|
+
"trigger_type": "cli_python",
|
|
207
|
+
"client_version": __version__,
|
|
208
|
+
"base_sha": context.base_sha,
|
|
209
|
+
"head_sha": context.head_sha,
|
|
210
|
+
"policy_config_hash": repository.get("policy", {}).get("config_hash") or config_hash(config),
|
|
211
|
+
"delivery_mode": "commit" if write else "preview",
|
|
212
|
+
},
|
|
213
|
+
idempotency_key=key,
|
|
214
|
+
)
|
|
215
|
+
run_id = created["generation"]["id"]
|
|
216
|
+
client.post(
|
|
217
|
+
f"/v1/generations/{run_id}/context",
|
|
218
|
+
{
|
|
219
|
+
"files": context.files,
|
|
220
|
+
"change": {"files": context.changed_files, "diff": context.diff},
|
|
221
|
+
},
|
|
222
|
+
)
|
|
223
|
+
deadline = time.monotonic() + args.timeout
|
|
224
|
+
generation: dict[str, Any] = {}
|
|
225
|
+
while time.monotonic() < deadline:
|
|
226
|
+
generation = client.get(f"/v1/generations/{run_id}")["generation"]
|
|
227
|
+
if generation["status"] in TERMINAL:
|
|
228
|
+
break
|
|
229
|
+
time.sleep(args.poll_interval)
|
|
230
|
+
if generation.get("status") != "completed":
|
|
231
|
+
raise GenerationError(
|
|
232
|
+
f"Generation {run_id} ended with status {generation.get('status', 'timeout')}"
|
|
233
|
+
)
|
|
234
|
+
artifact = client.get(f"/v1/generations/{run_id}/artifact")["artifact"]
|
|
235
|
+
report = apply_artifact(root, artifact, write)
|
|
236
|
+
verification = run_verification(root, verification_commands)
|
|
237
|
+
if not args.json and report.diff:
|
|
238
|
+
print(report.diff)
|
|
239
|
+
return {
|
|
240
|
+
"run_id": run_id,
|
|
241
|
+
"status": generation["status"],
|
|
242
|
+
"mode": "write" if write else "dry-run",
|
|
243
|
+
"repository_root": str(root),
|
|
244
|
+
"generated": report.generated,
|
|
245
|
+
"modified": report.modified,
|
|
246
|
+
"skipped": report.skipped,
|
|
247
|
+
"conflicted": report.conflicted,
|
|
248
|
+
"verification": verification,
|
|
249
|
+
"diff": report.diff if args.json else None,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def status(args: argparse.Namespace) -> dict[str, Any]:
|
|
254
|
+
return _client(args).get(f"/v1/generations/{args.run_id}")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def usage(args: argparse.Namespace) -> dict[str, Any]:
|
|
258
|
+
return _client(args).get("/v1/usage")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def parser() -> argparse.ArgumentParser:
|
|
262
|
+
root = argparse.ArgumentParser(prog="noswag")
|
|
263
|
+
root.add_argument("--api-url")
|
|
264
|
+
root.add_argument("--api-key")
|
|
265
|
+
root.add_argument("--json", action="store_true")
|
|
266
|
+
root.add_argument("--version", action="version", version=__version__)
|
|
267
|
+
commands = root.add_subparsers(dest="command", required=True)
|
|
268
|
+
auth = commands.add_parser("auth")
|
|
269
|
+
auth_commands = auth.add_subparsers(dest="auth_command", required=True)
|
|
270
|
+
login = auth_commands.add_parser("login")
|
|
271
|
+
login.add_argument("--no-browser", action="store_true")
|
|
272
|
+
login.set_defaults(handler=auth_login)
|
|
273
|
+
auth_commands.add_parser("status").set_defaults(handler=auth_status)
|
|
274
|
+
auth_commands.add_parser("logout").set_defaults(handler=auth_logout)
|
|
275
|
+
init = commands.add_parser("init")
|
|
276
|
+
init.add_argument("--force", action="store_true")
|
|
277
|
+
init.set_defaults(handler=init_repo)
|
|
278
|
+
doctor_command = commands.add_parser("doctor")
|
|
279
|
+
doctor_command.add_argument("--config", default=".noswag.yml")
|
|
280
|
+
doctor_command.add_argument("--base")
|
|
281
|
+
doctor_command.add_argument("--head", default="HEAD")
|
|
282
|
+
doctor_command.set_defaults(handler=doctor)
|
|
283
|
+
generate = commands.add_parser("gentest")
|
|
284
|
+
generate.add_argument("--config", default=".noswag.yml")
|
|
285
|
+
generate.add_argument("--spec", help="Generate contract tests from a repository-relative OpenAPI/Swagger file")
|
|
286
|
+
generate.add_argument("--base")
|
|
287
|
+
generate.add_argument("--head", default="HEAD")
|
|
288
|
+
generate.add_argument("--dry-run", action="store_true")
|
|
289
|
+
generate.add_argument("--write", action="store_true")
|
|
290
|
+
generate.add_argument("--verify", action="store_true")
|
|
291
|
+
generate.add_argument("--timeout", type=float, default=300)
|
|
292
|
+
generate.add_argument("--poll-interval", type=float, default=2)
|
|
293
|
+
generate.set_defaults(handler=gentest)
|
|
294
|
+
status_command = commands.add_parser("status")
|
|
295
|
+
status_command.add_argument("run_id")
|
|
296
|
+
status_command.set_defaults(handler=status)
|
|
297
|
+
commands.add_parser("usage").set_defaults(handler=usage)
|
|
298
|
+
return root
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def main(argv: list[str] | None = None) -> int:
|
|
302
|
+
args = parser().parse_args(argv)
|
|
303
|
+
try:
|
|
304
|
+
result = args.handler(args)
|
|
305
|
+
_emit(result, args.json)
|
|
306
|
+
return 0
|
|
307
|
+
except NoSwagError as error:
|
|
308
|
+
if args.json:
|
|
309
|
+
_emit({"error": {"message": str(error), "type": type(error).__name__}}, True)
|
|
310
|
+
else:
|
|
311
|
+
print(f"error: {error}", file=sys.stderr)
|
|
312
|
+
return error.exit_code
|