noswag-cli 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.
- noswag/__init__.py +3 -0
- noswag/__main__.py +3 -0
- noswag/api.py +134 -0
- noswag/cli.py +312 -0
- noswag/config.py +141 -0
- noswag/credentials.py +90 -0
- noswag/errors.py +36 -0
- noswag/generated/__init__.py +1 -0
- noswag/generated/public_v1.py +198 -0
- noswag/repository.py +293 -0
- noswag/verification.py +67 -0
- noswag/writer.py +105 -0
- noswag_cli-0.1.0.dist-info/METADATA +58 -0
- noswag_cli-0.1.0.dist-info/RECORD +16 -0
- noswag_cli-0.1.0.dist-info/WHEEL +4 -0
- noswag_cli-0.1.0.dist-info/entry_points.txt +2 -0
noswag/__init__.py
ADDED
noswag/__main__.py
ADDED
noswag/api.py
ADDED
|
@@ -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)
|
noswag/cli.py
ADDED
|
@@ -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
|
noswag/config.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .errors import ValidationError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Limits:
|
|
12
|
+
max_changed_files: int = 50
|
|
13
|
+
max_context_files: int = 100
|
|
14
|
+
max_file_bytes: int = 128_000
|
|
15
|
+
max_total_bytes: int = 1_500_000
|
|
16
|
+
max_generated_files: int = 10
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Config:
|
|
21
|
+
include: tuple[str, ...] = ("src/**", "app/**", "apps/**", "packages/**", "tests/**")
|
|
22
|
+
exclude: tuple[str, ...] = (
|
|
23
|
+
"node_modules/**",
|
|
24
|
+
"dist/**",
|
|
25
|
+
"build/**",
|
|
26
|
+
"vendor/**",
|
|
27
|
+
".git/**",
|
|
28
|
+
"**/*.generated.*",
|
|
29
|
+
)
|
|
30
|
+
languages: tuple[str, ...] = ()
|
|
31
|
+
frameworks: tuple[str, ...] = ()
|
|
32
|
+
scope: str = "changed"
|
|
33
|
+
allow_new_dependencies: bool = False
|
|
34
|
+
write_mode: str = "dry-run"
|
|
35
|
+
protect_existing: bool = True
|
|
36
|
+
verify_enabled: bool = False
|
|
37
|
+
verify_commands: tuple[str, ...] = ()
|
|
38
|
+
limits: Limits = field(default_factory=Limits)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _mapping(value: Any, name: str) -> dict[str, Any]:
|
|
42
|
+
if value is None:
|
|
43
|
+
return {}
|
|
44
|
+
if not isinstance(value, dict):
|
|
45
|
+
raise ValidationError(f"{name} must be a mapping")
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _strings(value: Any, name: str, default: tuple[str, ...]) -> tuple[str, ...]:
|
|
50
|
+
if value is None:
|
|
51
|
+
return default
|
|
52
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
53
|
+
raise ValidationError(f"{name} must be a string array")
|
|
54
|
+
return tuple(value)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _positive(value: Any, name: str, default: int, maximum: int) -> int:
|
|
58
|
+
if value is None:
|
|
59
|
+
return default
|
|
60
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 1 or value > maximum:
|
|
61
|
+
raise ValidationError(f"{name} must be an integer between 1 and {maximum}")
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_config(root: Path, config_path: str = ".noswag.yml") -> Config:
|
|
66
|
+
path = (root / config_path).resolve()
|
|
67
|
+
if root.resolve() not in (path, *path.parents):
|
|
68
|
+
raise ValidationError("Configuration path escapes the repository")
|
|
69
|
+
if not path.exists():
|
|
70
|
+
return Config()
|
|
71
|
+
try:
|
|
72
|
+
import yaml
|
|
73
|
+
except ImportError as error:
|
|
74
|
+
raise ValidationError("PyYAML is required to read .noswag.yml") from error
|
|
75
|
+
data = yaml.safe_load(path.read_text()) or {}
|
|
76
|
+
if not isinstance(data, dict):
|
|
77
|
+
raise ValidationError(".noswag.yml must contain a mapping")
|
|
78
|
+
if data.get("version") != 1:
|
|
79
|
+
raise ValidationError(".noswag.yml version must be 1")
|
|
80
|
+
generation = _mapping(data.get("generation"), "generation")
|
|
81
|
+
paths = _mapping(data.get("paths"), "paths")
|
|
82
|
+
limits = _mapping(data.get("limits"), "limits")
|
|
83
|
+
write = _mapping(data.get("write"), "write")
|
|
84
|
+
verify = _mapping(data.get("verify"), "verify")
|
|
85
|
+
scope = generation.get("scope", "changed")
|
|
86
|
+
if scope not in {"changed", "full"}:
|
|
87
|
+
raise ValidationError("generation.scope must be changed or full")
|
|
88
|
+
mode = write.get("mode", "dry-run")
|
|
89
|
+
if mode not in {"dry-run", "write"}:
|
|
90
|
+
raise ValidationError("write.mode must be dry-run or write")
|
|
91
|
+
if "enabled" in verify and not isinstance(verify["enabled"], bool):
|
|
92
|
+
raise ValidationError("verify.enabled must be a boolean")
|
|
93
|
+
defaults = Limits()
|
|
94
|
+
return Config(
|
|
95
|
+
include=_strings(paths.get("include"), "paths.include", Config.include),
|
|
96
|
+
exclude=_strings(paths.get("exclude"), "paths.exclude", Config.exclude),
|
|
97
|
+
languages=_strings(generation.get("languages"), "generation.languages", ()),
|
|
98
|
+
frameworks=_strings(generation.get("frameworks"), "generation.frameworks", ()),
|
|
99
|
+
scope=scope,
|
|
100
|
+
allow_new_dependencies=bool(generation.get("allow_new_dependencies", False)),
|
|
101
|
+
write_mode=mode,
|
|
102
|
+
protect_existing=bool(write.get("protect_existing", True)),
|
|
103
|
+
verify_enabled=verify.get("enabled", False) is True,
|
|
104
|
+
verify_commands=_strings(verify.get("commands"), "verify.commands", ()),
|
|
105
|
+
limits=Limits(
|
|
106
|
+
max_changed_files=_positive(limits.get("max_changed_files"), "limits.max_changed_files", defaults.max_changed_files, 100),
|
|
107
|
+
max_context_files=_positive(limits.get("max_context_files"), "limits.max_context_files", defaults.max_context_files, 200),
|
|
108
|
+
max_file_bytes=_positive(limits.get("max_file_bytes"), "limits.max_file_bytes", defaults.max_file_bytes, 1_000_000),
|
|
109
|
+
max_total_bytes=_positive(limits.get("max_total_bytes"), "limits.max_total_bytes", defaults.max_total_bytes, 10_000_000),
|
|
110
|
+
max_generated_files=_positive(limits.get("max_generated_files"), "limits.max_generated_files", defaults.max_generated_files, 50),
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
DEFAULT_CONFIG = """version: 1
|
|
116
|
+
|
|
117
|
+
generation:
|
|
118
|
+
scope: changed
|
|
119
|
+
languages: []
|
|
120
|
+
frameworks: []
|
|
121
|
+
allow_new_dependencies: false
|
|
122
|
+
|
|
123
|
+
paths:
|
|
124
|
+
include: ["src/**", "app/**", "apps/**", "packages/**", "tests/**"]
|
|
125
|
+
exclude: ["node_modules/**", "dist/**", "build/**", "vendor/**", "**/*.generated.*"]
|
|
126
|
+
|
|
127
|
+
limits:
|
|
128
|
+
max_changed_files: 50
|
|
129
|
+
max_context_files: 100
|
|
130
|
+
max_file_bytes: 128000
|
|
131
|
+
max_total_bytes: 1500000
|
|
132
|
+
max_generated_files: 10
|
|
133
|
+
|
|
134
|
+
write:
|
|
135
|
+
mode: dry-run
|
|
136
|
+
protect_existing: true
|
|
137
|
+
|
|
138
|
+
verify:
|
|
139
|
+
enabled: false
|
|
140
|
+
commands: []
|
|
141
|
+
"""
|
noswag/credentials.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict, dataclass
|
|
5
|
+
from typing import Any, Protocol
|
|
6
|
+
|
|
7
|
+
from .errors import AuthenticationError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Credential:
|
|
12
|
+
token: str
|
|
13
|
+
workspace_id: str | None = None
|
|
14
|
+
refresh_token: str | None = None
|
|
15
|
+
expires_at: float | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CredentialStore(Protocol):
|
|
19
|
+
def load(self, api_url: str) -> Credential | None: ...
|
|
20
|
+
def save(self, api_url: str, credential: Credential) -> None: ...
|
|
21
|
+
def delete(self, api_url: str) -> None: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class KeyringCredentialStore:
|
|
25
|
+
service = "io.noswag.cli"
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def _key(api_url: str) -> str:
|
|
29
|
+
return api_url.rstrip("/")
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def _keyring() -> Any:
|
|
33
|
+
try:
|
|
34
|
+
import keyring
|
|
35
|
+
except ImportError as error:
|
|
36
|
+
raise AuthenticationError(
|
|
37
|
+
"OS credential storage is unavailable; install the package with keyring support"
|
|
38
|
+
) from error
|
|
39
|
+
return keyring
|
|
40
|
+
|
|
41
|
+
def load(self, api_url: str) -> Credential | None:
|
|
42
|
+
value = self._keyring().get_password(self.service, self._key(api_url))
|
|
43
|
+
if not value:
|
|
44
|
+
return None
|
|
45
|
+
try:
|
|
46
|
+
data: Any = json.loads(value)
|
|
47
|
+
if not isinstance(data, dict):
|
|
48
|
+
raise TypeError("credential must be an object")
|
|
49
|
+
token = data.get("token")
|
|
50
|
+
workspace_id = data.get("workspace_id")
|
|
51
|
+
refresh_token = data.get("refresh_token")
|
|
52
|
+
expires_at = data.get("expires_at")
|
|
53
|
+
if (
|
|
54
|
+
not isinstance(token, str)
|
|
55
|
+
or (
|
|
56
|
+
workspace_id is not None
|
|
57
|
+
and not isinstance(workspace_id, str)
|
|
58
|
+
)
|
|
59
|
+
or (
|
|
60
|
+
refresh_token is not None
|
|
61
|
+
and not isinstance(refresh_token, str)
|
|
62
|
+
)
|
|
63
|
+
or (
|
|
64
|
+
expires_at is not None
|
|
65
|
+
and not isinstance(expires_at, (int, float))
|
|
66
|
+
)
|
|
67
|
+
):
|
|
68
|
+
raise TypeError("credential fields are invalid")
|
|
69
|
+
return Credential(
|
|
70
|
+
token=token,
|
|
71
|
+
workspace_id=workspace_id,
|
|
72
|
+
refresh_token=refresh_token,
|
|
73
|
+
expires_at=float(expires_at) if expires_at is not None else None,
|
|
74
|
+
)
|
|
75
|
+
except (KeyError, TypeError, json.JSONDecodeError) as error:
|
|
76
|
+
raise AuthenticationError("Stored NoSwag credential is invalid; run auth logout") from error
|
|
77
|
+
|
|
78
|
+
def save(self, api_url: str, credential: Credential) -> None:
|
|
79
|
+
self._keyring().set_password(
|
|
80
|
+
self.service,
|
|
81
|
+
self._key(api_url),
|
|
82
|
+
json.dumps(asdict(credential), separators=(",", ":")),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def delete(self, api_url: str) -> None:
|
|
86
|
+
keyring = self._keyring()
|
|
87
|
+
try:
|
|
88
|
+
keyring.delete_password(self.service, self._key(api_url))
|
|
89
|
+
except keyring.errors.PasswordDeleteError:
|
|
90
|
+
return
|