apizit-cli 0.2.0b1__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.
- apizit_cli/__init__.py +3 -0
- apizit_cli/authentication.py +396 -0
- apizit_cli/commands.py +337 -0
- apizit_cli/configuration.py +246 -0
- apizit_cli/context.py +155 -0
- apizit_cli/credential_store.py +170 -0
- apizit_cli/errors.py +19 -0
- apizit_cli/http.py +162 -0
- apizit_cli/main.py +164 -0
- apizit_cli/operations.py +745 -0
- apizit_cli/sources.py +259 -0
- apizit_cli-0.2.0b1.dist-info/METADATA +44 -0
- apizit_cli-0.2.0b1.dist-info/RECORD +16 -0
- apizit_cli-0.2.0b1.dist-info/WHEEL +5 -0
- apizit_cli-0.2.0b1.dist-info/entry_points.txt +2 -0
- apizit_cli-0.2.0b1.dist-info/top_level.txt +1 -0
apizit_cli/sources.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Deterministic packaging of local source files, without executing customer code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import gzip
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import stat
|
|
10
|
+
import subprocess
|
|
11
|
+
import tarfile
|
|
12
|
+
import tempfile
|
|
13
|
+
import unicodedata
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from pathspec import GitIgnoreSpec
|
|
18
|
+
|
|
19
|
+
from apizit_cli.errors import CliError, EXIT_USAGE
|
|
20
|
+
|
|
21
|
+
MAX_BUNDLE_BYTES = 64 * 1024 * 1024
|
|
22
|
+
MAX_SOURCE_BYTES = 512 * 1024 * 1024
|
|
23
|
+
MAX_FILES = 20_000
|
|
24
|
+
IGNORED_DIRECTORIES = frozenset(
|
|
25
|
+
{
|
|
26
|
+
".git",
|
|
27
|
+
".hg",
|
|
28
|
+
".svn",
|
|
29
|
+
".apizit",
|
|
30
|
+
".aws",
|
|
31
|
+
".ssh",
|
|
32
|
+
".azure",
|
|
33
|
+
".gnupg",
|
|
34
|
+
".kube",
|
|
35
|
+
".venv",
|
|
36
|
+
"venv",
|
|
37
|
+
"env",
|
|
38
|
+
"__pycache__",
|
|
39
|
+
".pytest_cache",
|
|
40
|
+
".ruff_cache",
|
|
41
|
+
".mypy_cache",
|
|
42
|
+
".tox",
|
|
43
|
+
"node_modules",
|
|
44
|
+
".aws-sam",
|
|
45
|
+
"generated",
|
|
46
|
+
"dist",
|
|
47
|
+
"build",
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
IGNORED_FILES = frozenset(
|
|
51
|
+
{".netrc", ".npmrc", ".pypirc", "credentials", "id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"}
|
|
52
|
+
)
|
|
53
|
+
PRIVATE_SUFFIXES = (".pem", ".key", ".p12", ".pfx", ".pyc", ".pyo")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class LocalBundle:
|
|
58
|
+
path: Path
|
|
59
|
+
digest_sha256: str
|
|
60
|
+
size_bytes: int
|
|
61
|
+
file_count: int
|
|
62
|
+
unpacked_size_bytes: int
|
|
63
|
+
source_commit_sha: str | None
|
|
64
|
+
working_tree_dirty: bool | None
|
|
65
|
+
excluded: tuple[str, ...]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _failure(message):
|
|
69
|
+
return CliError(message, code="LOCAL_SOURCE_INVALID", exit_code=EXIT_USAGE)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _unsafe_stat(info):
|
|
73
|
+
return stat.S_ISLNK(info.st_mode) or bool(getattr(info, "st_file_attributes", 0) & 0x400)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _ignore_spec(path):
|
|
77
|
+
if not path.exists():
|
|
78
|
+
return GitIgnoreSpec.from_lines([])
|
|
79
|
+
info = path.lstat()
|
|
80
|
+
if _unsafe_stat(info) or not stat.S_ISREG(info.st_mode) or info.st_size > 64 * 1024:
|
|
81
|
+
raise _failure("Ignore rules must be regular UTF-8 files of at most 64 KiB.")
|
|
82
|
+
try:
|
|
83
|
+
return GitIgnoreSpec.from_lines(path.read_text(encoding="utf-8").splitlines())
|
|
84
|
+
except (OSError, UnicodeError, ValueError):
|
|
85
|
+
raise _failure("Could not read source ignore rules.") from None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _collect(root):
|
|
89
|
+
included = []
|
|
90
|
+
excluded = []
|
|
91
|
+
collisions = set()
|
|
92
|
+
total = 0
|
|
93
|
+
apizit_ignore = _ignore_spec(root / ".apizitignore")
|
|
94
|
+
|
|
95
|
+
def walk(directory, rules):
|
|
96
|
+
nonlocal total
|
|
97
|
+
local_rule = directory / ".gitignore"
|
|
98
|
+
if local_rule.exists():
|
|
99
|
+
rules = [*rules, (directory, _ignore_spec(local_rule))]
|
|
100
|
+
for path in sorted(directory.iterdir(), key=lambda item: item.name):
|
|
101
|
+
relative = path.relative_to(root).as_posix()
|
|
102
|
+
info = path.lstat()
|
|
103
|
+
if _unsafe_stat(info):
|
|
104
|
+
raise _failure(f"Source links are not supported: {relative}")
|
|
105
|
+
is_directory = stat.S_ISDIR(info.st_mode)
|
|
106
|
+
name = path.name.lower()
|
|
107
|
+
forbidden = (
|
|
108
|
+
name in IGNORED_DIRECTORIES
|
|
109
|
+
if is_directory
|
|
110
|
+
else (
|
|
111
|
+
name in IGNORED_FILES
|
|
112
|
+
or name.startswith(".env")
|
|
113
|
+
or name.endswith(PRIVATE_SUFFIXES)
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
ignored = False
|
|
117
|
+
for base, spec in rules:
|
|
118
|
+
match = spec.check_file(
|
|
119
|
+
path.relative_to(base).as_posix() + ("/" if is_directory else "")
|
|
120
|
+
)
|
|
121
|
+
if match.include is not None:
|
|
122
|
+
ignored = match.include
|
|
123
|
+
ignored = ignored or apizit_ignore.match_file(relative + ("/" if is_directory else ""))
|
|
124
|
+
if forbidden or ignored:
|
|
125
|
+
excluded.append(relative + ("/" if is_directory else ""))
|
|
126
|
+
continue
|
|
127
|
+
normalized = unicodedata.normalize("NFC", relative)
|
|
128
|
+
if (
|
|
129
|
+
normalized != relative
|
|
130
|
+
or "\\" in relative
|
|
131
|
+
or ":" in relative
|
|
132
|
+
or any(ord(c) < 32 for c in relative)
|
|
133
|
+
):
|
|
134
|
+
raise _failure("Source paths must use normalized names without control characters.")
|
|
135
|
+
identity = normalized.casefold()
|
|
136
|
+
if identity in collisions:
|
|
137
|
+
raise _failure(f"Source paths collide across platforms: {relative}")
|
|
138
|
+
collisions.add(identity)
|
|
139
|
+
if is_directory:
|
|
140
|
+
walk(path, rules)
|
|
141
|
+
elif stat.S_ISREG(info.st_mode):
|
|
142
|
+
if info.st_nlink > 1:
|
|
143
|
+
raise _failure(f"Hard-linked files are not supported: {relative}")
|
|
144
|
+
total += info.st_size
|
|
145
|
+
included.append((relative, path, info))
|
|
146
|
+
if len(included) > MAX_FILES or total > MAX_SOURCE_BYTES:
|
|
147
|
+
raise _failure("Local source exceeds the file-count or expanded-size limit.")
|
|
148
|
+
else:
|
|
149
|
+
raise _failure(f"Only ordinary files and directories are supported: {relative}")
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
walk(root, [])
|
|
153
|
+
except OSError:
|
|
154
|
+
raise _failure("The source contains unreadable files.") from None
|
|
155
|
+
if not included:
|
|
156
|
+
raise _failure("The project has no files after applying source exclusions.")
|
|
157
|
+
return sorted(included), tuple(excluded), total
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _git_provenance(root):
|
|
161
|
+
try:
|
|
162
|
+
command = ["git", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=", "-C", str(root)]
|
|
163
|
+
head = (
|
|
164
|
+
subprocess.run(
|
|
165
|
+
[*command, "rev-parse", "HEAD"], capture_output=True, check=True, timeout=10
|
|
166
|
+
)
|
|
167
|
+
.stdout.decode()
|
|
168
|
+
.strip()
|
|
169
|
+
)
|
|
170
|
+
if len(head) != 40 or any(c not in "0123456789abcdef" for c in head):
|
|
171
|
+
return None, None
|
|
172
|
+
status = subprocess.run(
|
|
173
|
+
[*command, "status", "--porcelain", "--untracked-files=normal", "--", "."],
|
|
174
|
+
capture_output=True,
|
|
175
|
+
check=True,
|
|
176
|
+
timeout=10,
|
|
177
|
+
).stdout
|
|
178
|
+
return head, bool(status)
|
|
179
|
+
except (OSError, subprocess.SubprocessError, UnicodeError):
|
|
180
|
+
return None, None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def pack_local_source(root: Path, destination: Path) -> LocalBundle:
|
|
184
|
+
root = Path(root).resolve(strict=True)
|
|
185
|
+
destination = Path(destination).resolve()
|
|
186
|
+
if not root.is_dir() or destination.is_relative_to(root):
|
|
187
|
+
raise _failure("Package output must be outside the source directory.")
|
|
188
|
+
included, excluded, total = _collect(root)
|
|
189
|
+
manifest = []
|
|
190
|
+
try:
|
|
191
|
+
with destination.open("xb") as raw:
|
|
192
|
+
with gzip.GzipFile(
|
|
193
|
+
filename="", fileobj=raw, mode="wb", mtime=0, compresslevel=6
|
|
194
|
+
) as compressed:
|
|
195
|
+
with tarfile.open(
|
|
196
|
+
fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT
|
|
197
|
+
) as archive:
|
|
198
|
+
for relative, path, original in included:
|
|
199
|
+
digest = hashlib.sha256()
|
|
200
|
+
size = 0
|
|
201
|
+
with (
|
|
202
|
+
path.open("rb") as source,
|
|
203
|
+
tempfile.SpooledTemporaryFile(max_size=2 * 1024 * 1024) as buffered,
|
|
204
|
+
):
|
|
205
|
+
opened = os.fstat(source.fileno())
|
|
206
|
+
if (opened.st_ino, opened.st_size, opened.st_mtime_ns) != (
|
|
207
|
+
original.st_ino,
|
|
208
|
+
original.st_size,
|
|
209
|
+
original.st_mtime_ns,
|
|
210
|
+
):
|
|
211
|
+
raise _failure(f"Source changed during packaging: {relative}")
|
|
212
|
+
while chunk := source.read(1024 * 1024):
|
|
213
|
+
size += len(chunk)
|
|
214
|
+
if size > original.st_size:
|
|
215
|
+
raise _failure(f"Source changed during packaging: {relative}")
|
|
216
|
+
if size <= 1024 * 1024 and b"PRIVATE KEY-----" in chunk:
|
|
217
|
+
raise _failure(
|
|
218
|
+
f"A private key must not be uploaded: {relative}"
|
|
219
|
+
)
|
|
220
|
+
digest.update(chunk)
|
|
221
|
+
buffered.write(chunk)
|
|
222
|
+
after = path.lstat()
|
|
223
|
+
if (
|
|
224
|
+
_unsafe_stat(after)
|
|
225
|
+
or size != original.st_size
|
|
226
|
+
or (after.st_ino, after.st_size, after.st_mtime_ns)
|
|
227
|
+
!= (original.st_ino, original.st_size, original.st_mtime_ns)
|
|
228
|
+
):
|
|
229
|
+
raise _failure(f"Source changed during packaging: {relative}")
|
|
230
|
+
buffered.seek(0)
|
|
231
|
+
member = tarfile.TarInfo(relative)
|
|
232
|
+
member.size = size
|
|
233
|
+
member.mode = 0o755 if original.st_mode & 0o111 else 0o644
|
|
234
|
+
archive.addfile(member, buffered)
|
|
235
|
+
manifest.append(
|
|
236
|
+
{"path": relative, "size_bytes": size, "sha256": digest.hexdigest()}
|
|
237
|
+
)
|
|
238
|
+
if raw.tell() > MAX_BUNDLE_BYTES:
|
|
239
|
+
raise _failure("Compressed source exceeds 64 MiB.")
|
|
240
|
+
# The manifest is carried alongside the archive in the client result, rather
|
|
241
|
+
# than adding a platform-generated file to the customer's source tree.
|
|
242
|
+
size_bytes = destination.stat().st_size
|
|
243
|
+
if size_bytes > MAX_BUNDLE_BYTES:
|
|
244
|
+
raise _failure("Compressed source exceeds 64 MiB.")
|
|
245
|
+
with destination.open("rb") as stream:
|
|
246
|
+
digest = hashlib.file_digest(stream, "sha256").hexdigest()
|
|
247
|
+
head, dirty = _git_provenance(root)
|
|
248
|
+
destination.with_suffix(destination.suffix + ".manifest.json").write_text(
|
|
249
|
+
json.dumps(
|
|
250
|
+
{"version": 1, "ignore_policy_version": 1, "files": manifest}, separators=(",", ":")
|
|
251
|
+
),
|
|
252
|
+
encoding="utf-8",
|
|
253
|
+
)
|
|
254
|
+
return LocalBundle(
|
|
255
|
+
destination, "sha256:" + digest, size_bytes, len(included), total, head, dirty, excluded
|
|
256
|
+
)
|
|
257
|
+
except Exception:
|
|
258
|
+
destination.unlink(missing_ok=True)
|
|
259
|
+
raise
|
|
@@ -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,16 @@
|
|
|
1
|
+
apizit_cli/__init__.py,sha256=4oWfrWOg004-aoBe9Ph2KS2l-XzV4G6Vt75GJhw9vks,107
|
|
2
|
+
apizit_cli/authentication.py,sha256=fBRKL-N9qsgGc3auIkcs_bVNZsqJKv-h9_91GLGjvQY,14941
|
|
3
|
+
apizit_cli/commands.py,sha256=qoVReU5DXqwzrMCTFnVcZRQRQN1DLR6wVTw492ztwpk,13312
|
|
4
|
+
apizit_cli/configuration.py,sha256=lFAw6kzJdhfZIGdb91bBtKR-uKdeEX_dQZ5c5C1S6oQ,9978
|
|
5
|
+
apizit_cli/context.py,sha256=CsTRiNrWwIYdSmlgdm4W_MahtQjm7Az-J7k-Tg2TCng,5600
|
|
6
|
+
apizit_cli/credential_store.py,sha256=NZM6cfmrqHt1CGAp7OJsumacSpC9rGycleRziODnV7Q,6772
|
|
7
|
+
apizit_cli/errors.py,sha256=JvsX2ymALFpyJjXp7EEAdrWuRj9D_82VvrH7zRXcjy0,442
|
|
8
|
+
apizit_cli/http.py,sha256=CPeQPF-MLg4LjoTEwOB4fqOESYkJEt5XNk6JDaXxeyg,6212
|
|
9
|
+
apizit_cli/main.py,sha256=OmgNJ0jV1e2xs5W5NMql1tDrikTrFcOl4DhHLr9XTjQ,5966
|
|
10
|
+
apizit_cli/operations.py,sha256=LyyBAEnSIJI5gY2X5-loOIo-g6la8Qwt5NKvt-tSKGM,32391
|
|
11
|
+
apizit_cli/sources.py,sha256=Xs03bPpQVaZQrpXTn7q-e1XXU310ngc9yy7d3lU1Jpk,10182
|
|
12
|
+
apizit_cli-0.2.0b1.dist-info/METADATA,sha256=4FQ87TUTfUyaV-95JwHDB7sqcC_ee8Jj7qAbUc7euew,1763
|
|
13
|
+
apizit_cli-0.2.0b1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
14
|
+
apizit_cli-0.2.0b1.dist-info/entry_points.txt,sha256=AhSrxJL-deUOAw4Oex2z3USK2QlZe8uzdN7N1HDfJHY,48
|
|
15
|
+
apizit_cli-0.2.0b1.dist-info/top_level.txt,sha256=YqJYTfV8JJFrJmgNMQ5opKuqLRGsxModqxKJJxD3W-w,11
|
|
16
|
+
apizit_cli-0.2.0b1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
apizit_cli
|