apizit-cli 0.2.0b1__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,44 @@
1
+ Metadata-Version: 2.4
2
+ Name: apizit-cli
3
+ Version: 0.2.0b1
4
+ Summary: Launch Python APIs with APIZIT from your terminal or CI.
5
+ Author: APIZIT
6
+ Classifier: Development Status :: 4 - Beta
7
+ Classifier: Environment :: Console
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Requires-Python: >=3.12
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: requests<3,>=2.32.0
14
+ Requires-Dist: pydantic<3,>=2.12.5
15
+ Requires-Dist: pyyaml<7,>=6.0.3
16
+ Requires-Dist: keyring<26,>=25.7
17
+ Requires-Dist: pathspec<2,>=1
18
+
19
+ # APIZIT CLI
20
+
21
+ Launch Python code as a managed public API, inspect logs and manage releases from your terminal.
22
+
23
+ APIZIT is an advanced development beta. This client targets its managed dev service.
24
+ Check the official CLI documentation for package availability and current workspace capabilities.
25
+
26
+ Install with `pipx install --pip-args=--pre apizit-cli`
27
+ or `uv tool install --prerelease allow apizit-cli`. Python 3.12 or later is required.
28
+ The client requires no AWS account, AWS credentials, Docker or APIZIT backend checkout.
29
+
30
+ Run `apizit --help` and `apizit docs` for the command reference.
31
+ The package contains the HTTP client and shared configuration contracts only.
32
+
33
+ ```sh
34
+ apizit login
35
+ apizit workspace list
36
+ apizit workspace use ws_YOUR_WORKSPACE
37
+ apizit scan . --wait
38
+ apizit deploy . --wait
39
+ ```
40
+
41
+ Git repositories and local folders use the same managed launch checks. The optional
42
+ `apizit_config.yaml` uses the strict version 2 schema available through `apizit config schema`.
43
+ Use `--json --non-interactive` and an expiring workspace token in `APIZIT_TOKEN` for CI or an AI
44
+ agent. Secrets belong in the workspace vault, never in source files or command-line arguments.
@@ -0,0 +1,26 @@
1
+ # APIZIT CLI
2
+
3
+ Launch Python code as a managed public API, inspect logs and manage releases from your terminal.
4
+
5
+ APIZIT is an advanced development beta. This client targets its managed dev service.
6
+ Check the official CLI documentation for package availability and current workspace capabilities.
7
+
8
+ Install with `pipx install --pip-args=--pre apizit-cli`
9
+ or `uv tool install --prerelease allow apizit-cli`. Python 3.12 or later is required.
10
+ The client requires no AWS account, AWS credentials, Docker or APIZIT backend checkout.
11
+
12
+ Run `apizit --help` and `apizit docs` for the command reference.
13
+ The package contains the HTTP client and shared configuration contracts only.
14
+
15
+ ```sh
16
+ apizit login
17
+ apizit workspace list
18
+ apizit workspace use ws_YOUR_WORKSPACE
19
+ apizit scan . --wait
20
+ apizit deploy . --wait
21
+ ```
22
+
23
+ Git repositories and local folders use the same managed launch checks. The optional
24
+ `apizit_config.yaml` uses the strict version 2 schema available through `apizit config schema`.
25
+ Use `--json --non-interactive` and an expiring workspace token in `APIZIT_TOKEN` for CI or an AI
26
+ agent. Secrets belong in the workspace vault, never in source files or command-line arguments.
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "apizit-cli"
7
+ version = "0.2.0b1"
8
+ description = "Launch Python APIs with APIZIT from your terminal or CI."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ authors = [{name = "APIZIT"}]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Environment :: Console",
15
+ "Operating System :: OS Independent",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.12",
18
+ ]
19
+ dependencies = [
20
+ "requests>=2.32.0,<3",
21
+ "pydantic>=2.12.5,<3",
22
+ "pyyaml>=6.0.3,<7",
23
+ "keyring>=25.7,<26",
24
+ "pathspec>=1,<2",
25
+ ]
26
+
27
+ [project.scripts]
28
+ apizit = "apizit_cli.main:main"
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
32
+ include = ["apizit_cli*"]
33
+
34
+ [tool.setuptools.package-data]
35
+ apizit_cli = ["data/*.json", "data/*.md", "data/*.yaml"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """The APIZIT HTTP client. Importing this package never initializes a session."""
2
+
3
+ __version__ = "0.2.0b1"
@@ -0,0 +1,396 @@
1
+ """Native OAuth with PKCE, a loopback callback and an OS credential vault."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import hmac
8
+ import json
9
+ import os
10
+ import secrets
11
+ import sys
12
+ import time
13
+ import webbrowser
14
+ from http.server import BaseHTTPRequestHandler, HTTPServer
15
+ from urllib.parse import parse_qs, urlencode, urlsplit
16
+
17
+ import requests
18
+
19
+ from apizit_cli.credential_store import CredentialStore, session_lock
20
+ from apizit_cli.errors import CliError, EXIT_AUTH, EXIT_NETWORK, EXIT_USAGE
21
+
22
+
23
+ def system_vault():
24
+ try:
25
+ if sys.platform == "win32":
26
+ from keyring.backends.Windows import WinVaultKeyring
27
+
28
+ vault = WinVaultKeyring()
29
+ elif sys.platform == "darwin":
30
+ from keyring.backends.macOS import Keyring
31
+
32
+ vault = Keyring()
33
+ else:
34
+ from keyring.backends.SecretService import Keyring
35
+
36
+ vault = Keyring()
37
+ if vault.priority <= 0:
38
+ raise RuntimeError()
39
+ return vault
40
+ except Exception:
41
+ raise CliError(
42
+ "The system credential vault is unavailable. Unlock it, or use an injected CI token.",
43
+ code="CREDENTIAL_VAULT_UNAVAILABLE",
44
+ exit_code=EXIT_AUTH,
45
+ ) from None
46
+
47
+
48
+ class NativeSession:
49
+ def __init__(self, origin, *, http=None, vault=None, injected_token=None):
50
+ self.origin = origin
51
+ self.http = http or requests.Session()
52
+ self.http.trust_env = False
53
+ self._vault = vault
54
+ self.injected_token = (
55
+ injected_token if injected_token is not None else os.getenv("APIZIT_TOKEN")
56
+ )
57
+ self.access_token = None
58
+ self.expires_at = 0
59
+ self._configuration = None
60
+ self.vault_service = "APIZIT CLI:" + origin
61
+
62
+ @property
63
+ def vault(self):
64
+ if self._vault is None:
65
+ self._vault = system_vault()
66
+ return self._vault
67
+
68
+ def configuration(self):
69
+ if self._configuration is not None:
70
+ return self._configuration
71
+ try:
72
+ response = self.http.get(
73
+ self.origin + "/auth/cli-configuration", timeout=20, allow_redirects=False
74
+ )
75
+ if response.status_code != 200:
76
+ raise ValueError()
77
+ value = response.json()
78
+ if not isinstance(value, dict) or not value.get("client_id"):
79
+ raise ValueError()
80
+ origins = set()
81
+ for field in ("authorization_endpoint", "token_endpoint", "revocation_endpoint"):
82
+ parsed = urlsplit(value[field])
83
+ if (
84
+ parsed.scheme != "https"
85
+ or not parsed.hostname
86
+ or parsed.username
87
+ or parsed.password
88
+ or parsed.query
89
+ or parsed.fragment
90
+ ):
91
+ raise ValueError()
92
+ origins.add((parsed.scheme, parsed.netloc))
93
+ if len(origins) != 1:
94
+ raise ValueError()
95
+ callback = urlsplit(value["redirect_uri"])
96
+ if (
97
+ callback.scheme != "http"
98
+ or callback.hostname != "127.0.0.1"
99
+ or callback.username
100
+ or callback.password
101
+ or not callback.port
102
+ or not callback.path.startswith("/")
103
+ or callback.query
104
+ or callback.fragment
105
+ ):
106
+ raise ValueError()
107
+ self._configuration = value
108
+ return value
109
+ except (requests.RequestException, ValueError, KeyError, TypeError):
110
+ raise CliError(
111
+ "The APIZIT login configuration is unavailable.",
112
+ code="LOGIN_CONFIGURATION_UNAVAILABLE",
113
+ exit_code=EXIT_NETWORK,
114
+ ) from None
115
+
116
+ def _stored(self):
117
+ try:
118
+ value = CredentialStore(self.vault, self.vault_service).read()
119
+ saved = json.loads(value) if value else None
120
+ if saved is not None and (
121
+ not isinstance(saved, dict) or not isinstance(saved.get("refresh_token"), str)
122
+ ):
123
+ raise ValueError()
124
+ return saved
125
+ except CliError:
126
+ raise
127
+ except Exception:
128
+ raise CliError(
129
+ "The APIZIT session could not be read from the credential vault.",
130
+ code="CREDENTIAL_VAULT_UNAVAILABLE",
131
+ exit_code=EXIT_AUTH,
132
+ ) from None
133
+
134
+ def _save(self, tokens, configuration, *, previous=None):
135
+ refresh = tokens.get("refresh_token") or (previous or {}).get("refresh_token")
136
+ access = tokens.get("access_token")
137
+ expiry = tokens.get("expires_in")
138
+ if (
139
+ not isinstance(refresh, str)
140
+ or not refresh
141
+ or not isinstance(access, str)
142
+ or not access
143
+ or type(expiry) is not int
144
+ or expiry <= 0
145
+ ):
146
+ raise CliError(
147
+ "The login provider returned an incomplete session.",
148
+ code="LOGIN_RESPONSE_INVALID",
149
+ exit_code=EXIT_AUTH,
150
+ )
151
+ try:
152
+ CredentialStore(self.vault, self.vault_service).write(
153
+ json.dumps(
154
+ {
155
+ "refresh_token": refresh,
156
+ "client_id": configuration["client_id"],
157
+ }
158
+ ),
159
+ )
160
+ except CliError:
161
+ raise
162
+ except Exception:
163
+ raise CliError(
164
+ "The APIZIT session could not be saved securely.",
165
+ code="CREDENTIAL_VAULT_UNAVAILABLE",
166
+ exit_code=EXIT_AUTH,
167
+ ) from None
168
+ self.access_token = access
169
+ self.expires_at = time.monotonic() + expiry
170
+
171
+ def _token_request(self, configuration, data):
172
+ try:
173
+ response = self.http.post(
174
+ configuration["token_endpoint"], data=data, timeout=30, allow_redirects=False
175
+ )
176
+ if response.status_code != 200:
177
+ raise CliError(
178
+ "The session was rejected. Run apizit login.",
179
+ code="LOGIN_REQUIRED",
180
+ exit_code=EXIT_AUTH,
181
+ )
182
+ result = response.json()
183
+ if not isinstance(result, dict):
184
+ raise ValueError()
185
+ return result
186
+ except (requests.RequestException, ValueError):
187
+ raise CliError(
188
+ "Could not reach the login provider.",
189
+ code="LOGIN_PROVIDER_UNAVAILABLE",
190
+ exit_code=EXIT_NETWORK,
191
+ ) from None
192
+
193
+ def token(self, *, force_refresh=False):
194
+ if self.injected_token:
195
+ if (
196
+ any(char.isspace() for char in self.injected_token)
197
+ or len(self.injected_token) > 8192
198
+ ):
199
+ raise CliError(
200
+ "Injected APIZIT token is invalid.", code="INVALID_TOKEN", exit_code=EXIT_AUTH
201
+ )
202
+ return self.injected_token
203
+ if self.access_token and not force_refresh and self.expires_at > time.monotonic() + 60:
204
+ return self.access_token
205
+ with session_lock(self.vault_service):
206
+ return self._renew()
207
+
208
+ def _renew(self):
209
+ saved = self._stored()
210
+ if not saved:
211
+ raise CliError(
212
+ "Sign in with apizit login or inject APIZIT_TOKEN.",
213
+ code="LOGIN_REQUIRED",
214
+ exit_code=EXIT_AUTH,
215
+ )
216
+ configuration = self.configuration()
217
+ if saved.get("client_id") != configuration["client_id"]:
218
+ raise CliError(
219
+ "The login configuration changed. Run apizit login.",
220
+ code="LOGIN_REQUIRED",
221
+ exit_code=EXIT_AUTH,
222
+ )
223
+ tokens = self._token_request(
224
+ configuration,
225
+ {
226
+ "grant_type": "refresh_token",
227
+ "client_id": configuration["client_id"],
228
+ "refresh_token": saved["refresh_token"],
229
+ },
230
+ )
231
+ self._save(tokens, configuration, previous=saved)
232
+ return self.access_token
233
+
234
+ def login(self, *, open_browser=True, non_interactive=False, timeout=180):
235
+ if non_interactive:
236
+ raise CliError(
237
+ "Interactive login is unavailable in non-interactive mode. Inject a CI token.",
238
+ code="INTERACTION_REQUIRED",
239
+ exit_code=EXIT_USAGE,
240
+ )
241
+ if self.injected_token:
242
+ raise CliError(
243
+ "Unset the injected token before starting an interactive login.",
244
+ code="INJECTED_TOKEN_ACTIVE",
245
+ exit_code=EXIT_USAGE,
246
+ )
247
+ configuration = self.configuration()
248
+ self.vault # Fail before OAuth if secure persistence is unavailable.
249
+ state = secrets.token_urlsafe(32)
250
+ verifier = secrets.token_urlsafe(64)
251
+ challenge = (
252
+ base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
253
+ .rstrip(b"=")
254
+ .decode()
255
+ )
256
+ callback = urlsplit(configuration["redirect_uri"])
257
+ outcome = {}
258
+ deadline = time.monotonic() + timeout
259
+
260
+ class Callback(BaseHTTPRequestHandler):
261
+ def setup(self):
262
+ self.request.settimeout(max(0.1, min(3.0, deadline - time.monotonic())))
263
+ super().setup()
264
+
265
+ def do_GET(self):
266
+ parsed = urlsplit(self.path)
267
+ try:
268
+ query = parse_qs(parsed.query, max_num_fields=8, strict_parsing=True)
269
+ except ValueError:
270
+ query = {}
271
+ valid = (
272
+ parsed.path == callback.path
273
+ and not parsed.scheme
274
+ and not parsed.netloc
275
+ and self.headers.get("Host") == callback.netloc
276
+ and len(query.get("state", [])) == 1
277
+ and hmac.compare_digest(query["state"][0].encode("utf-8"), state.encode())
278
+ and not ("code" in query and "error" in query)
279
+ and (
280
+ (len(query.get("code", [])) == 1 and 0 < len(query["code"][0]) <= 4096)
281
+ or (len(query.get("error", [])) == 1 and 0 < len(query["error"][0]) <= 256)
282
+ )
283
+ )
284
+ if valid and len(query.get("code", [])) == 1 and 0 < len(query["code"][0]) <= 4096:
285
+ outcome["code"] = query["code"][0]
286
+ elif valid and "error" in query:
287
+ outcome["error"] = True
288
+ self.send_response(200 if valid else 400)
289
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
290
+ self.send_header("Cache-Control", "no-store")
291
+ self.send_header("Content-Security-Policy", "default-src 'none'")
292
+ self.end_headers()
293
+ self.wfile.write(
294
+ b"Return to the APIZIT terminal." if valid else b"Invalid login callback."
295
+ )
296
+
297
+ def log_message(self, *_args):
298
+ pass
299
+
300
+ try:
301
+ server = HTTPServer(("127.0.0.1", callback.port), Callback)
302
+ except OSError:
303
+ raise CliError(
304
+ "The local login callback port is unavailable. Close the other login and retry.",
305
+ code="LOGIN_CALLBACK_UNAVAILABLE",
306
+ exit_code=EXIT_AUTH,
307
+ ) from None
308
+ url = (
309
+ configuration["authorization_endpoint"]
310
+ + "?"
311
+ + urlencode(
312
+ {
313
+ "response_type": "code",
314
+ "client_id": configuration["client_id"],
315
+ "redirect_uri": configuration["redirect_uri"],
316
+ "state": state,
317
+ "scope": " ".join(configuration["scopes"]),
318
+ "code_challenge": challenge,
319
+ "code_challenge_method": "S256",
320
+ }
321
+ )
322
+ )
323
+ print("Complete APIZIT sign-in in your browser:\n" + url, file=sys.stderr)
324
+ try:
325
+ if open_browser:
326
+ webbrowser.open(url)
327
+ server.timeout = 1
328
+ while not outcome and time.monotonic() < deadline:
329
+ server.handle_request()
330
+ finally:
331
+ server.server_close()
332
+ if "code" not in outcome:
333
+ raise CliError(
334
+ "Login was not completed. Run apizit login to retry.",
335
+ code="LOGIN_NOT_COMPLETED",
336
+ exit_code=EXIT_AUTH,
337
+ )
338
+ tokens = self._token_request(
339
+ configuration,
340
+ {
341
+ "grant_type": "authorization_code",
342
+ "code": outcome["code"],
343
+ "client_id": configuration["client_id"],
344
+ "redirect_uri": configuration["redirect_uri"],
345
+ "code_verifier": verifier,
346
+ },
347
+ )
348
+ with session_lock(self.vault_service):
349
+ self._save(tokens, configuration)
350
+ return {"signed_in": True}
351
+
352
+ def logout(self):
353
+ if self.injected_token:
354
+ raise CliError(
355
+ "An injected CI token is not a local session. Revoke it with token revoke, or unset APIZIT_TOKEN.",
356
+ code="INJECTED_TOKEN_ACTIVE",
357
+ exit_code=EXIT_USAGE,
358
+ )
359
+ with session_lock(self.vault_service):
360
+ return self._logout()
361
+
362
+ def _logout(self):
363
+ incomplete = False
364
+ try:
365
+ saved = self._stored()
366
+ except CliError:
367
+ saved, incomplete = None, True
368
+ revoked = not bool(saved) and not incomplete
369
+ try:
370
+ if saved:
371
+ configuration = self.configuration()
372
+ response = self.http.post(
373
+ configuration["revocation_endpoint"],
374
+ data={
375
+ "token": saved["refresh_token"],
376
+ "client_id": configuration["client_id"],
377
+ },
378
+ timeout=20,
379
+ allow_redirects=False,
380
+ )
381
+ revoked = response.status_code == 200
382
+ except (requests.RequestException, CliError):
383
+ revoked = False
384
+ finally:
385
+ if saved or incomplete:
386
+ try:
387
+ CredentialStore(self.vault, self.vault_service).delete()
388
+ except Exception:
389
+ raise CliError(
390
+ "The local session could not be removed from the vault.",
391
+ code="CREDENTIAL_VAULT_UNAVAILABLE",
392
+ exit_code=EXIT_AUTH,
393
+ ) from None
394
+ self.access_token = None
395
+ self.expires_at = 0
396
+ return {"signed_out": True, "renewal_revoked": revoked}