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 ADDED
@@ -0,0 +1,3 @@
1
+ """NoSwag public Python client."""
2
+
3
+ __version__ = "0.1.0"
noswag/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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