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.
@@ -0,0 +1,5 @@
1
+ dist/
2
+ *.egg-info/
3
+ __pycache__/
4
+ .mypy_cache/
5
+ .pytest_cache/
@@ -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,3 @@
1
+ """NoSwag public Python client."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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