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/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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ apizit = apizit_cli.main:main
@@ -0,0 +1 @@
1
+ apizit_cli