pxxl 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.
pxxl-0.1.0/.gitignore ADDED
@@ -0,0 +1,5 @@
1
+ node_modules/
2
+ dist/
3
+ target/
4
+ .DS_Store
5
+ coverage/
pxxl-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: pxxl
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for Pxxl deploys, CDN assets, domains, and cron jobs.
5
+ Project-URL: Homepage, https://pxxl.app
6
+ Project-URL: Documentation, https://docs.pxxl.app
7
+ Project-URL: Repository, https://github.com/pxxlspace/pxxlspace
8
+ Author: Pxxl
9
+ License-Expression: MIT
10
+ Keywords: cdn,cron,deploy,domains,pxxl,sdk
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+
23
+ # Pxxl Python SDK
24
+
25
+ Official Python client for Pxxl CDN uploads, domain search and DNS management, cron jobs, and source deploys.
26
+
27
+ ```bash
28
+ pip install pxxl
29
+ ```
30
+
31
+ ```python
32
+ from pxxl import PxxlClient
33
+
34
+ client = PxxlClient(api_key="pxxl_...")
35
+
36
+ asset = client.upload_asset(
37
+ file_path="logo.png",
38
+ visibility="public",
39
+ )
40
+ print(asset["publicUrl"])
41
+ ```
42
+
43
+ ## Domains
44
+
45
+ ```python
46
+ search = client.search_domains("example.cv")
47
+ connected = client.connect_domain("example.com", project_id="proj_123")
48
+ records = client.list_domain_dns_records("dom_123")
49
+ ```
50
+
51
+ Domain write operations require `scope=domain`, `scope=domains`, or `scope=all` with `permission=read_write`.
52
+
53
+ ## Cron Jobs
54
+
55
+ ```python
56
+ job = client.create_cron_job(
57
+ name="cache warmer",
58
+ schedule="*/5 * * * *",
59
+ url="https://example.com/api/warm-cache",
60
+ method="POST",
61
+ )
62
+ client.trigger_cron_job(job["id"])
63
+ ```
64
+
65
+ Cron mutations require `scope=cron`, `scope=cronjobs`, or `scope=all` with `permission=read_write`.
66
+
67
+ ## Deploy
68
+
69
+ ```python
70
+ result = client.deploy(
71
+ directory=".",
72
+ name="python-api",
73
+ domain_choice="pxxl.app",
74
+ language="python",
75
+ framework="fastapi",
76
+ start_command="uvicorn main:app --host 0.0.0.0 --port $PORT",
77
+ )
78
+ print(result["deploymentUrl"])
79
+ ```
pxxl-0.1.0/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # Pxxl Python SDK
2
+
3
+ Official Python client for Pxxl CDN uploads, domain search and DNS management, cron jobs, and source deploys.
4
+
5
+ ```bash
6
+ pip install pxxl
7
+ ```
8
+
9
+ ```python
10
+ from pxxl import PxxlClient
11
+
12
+ client = PxxlClient(api_key="pxxl_...")
13
+
14
+ asset = client.upload_asset(
15
+ file_path="logo.png",
16
+ visibility="public",
17
+ )
18
+ print(asset["publicUrl"])
19
+ ```
20
+
21
+ ## Domains
22
+
23
+ ```python
24
+ search = client.search_domains("example.cv")
25
+ connected = client.connect_domain("example.com", project_id="proj_123")
26
+ records = client.list_domain_dns_records("dom_123")
27
+ ```
28
+
29
+ Domain write operations require `scope=domain`, `scope=domains`, or `scope=all` with `permission=read_write`.
30
+
31
+ ## Cron Jobs
32
+
33
+ ```python
34
+ job = client.create_cron_job(
35
+ name="cache warmer",
36
+ schedule="*/5 * * * *",
37
+ url="https://example.com/api/warm-cache",
38
+ method="POST",
39
+ )
40
+ client.trigger_cron_job(job["id"])
41
+ ```
42
+
43
+ Cron mutations require `scope=cron`, `scope=cronjobs`, or `scope=all` with `permission=read_write`.
44
+
45
+ ## Deploy
46
+
47
+ ```python
48
+ result = client.deploy(
49
+ directory=".",
50
+ name="python-api",
51
+ domain_choice="pxxl.app",
52
+ language="python",
53
+ framework="fastapi",
54
+ start_command="uvicorn main:app --host 0.0.0.0 --port $PORT",
55
+ )
56
+ print(result["deploymentUrl"])
57
+ ```
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pxxl"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for Pxxl deploys, CDN assets, domains, and cron jobs."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [{ name = "Pxxl" }]
13
+ keywords = ["pxxl", "deploy", "cdn", "domains", "cron", "sdk"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
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.urls]
27
+ Homepage = "https://pxxl.app"
28
+ Documentation = "https://docs.pxxl.app"
29
+ Repository = "https://github.com/pxxlspace/pxxlspace"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/pxxl"]
@@ -0,0 +1,3 @@
1
+ from .client import PxxlAPIError, PxxlClient, create_project_zip
2
+
3
+ __all__ = ["PxxlAPIError", "PxxlClient", "create_project_zip"]
@@ -0,0 +1,335 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import mimetypes
5
+ import os
6
+ import secrets
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+ import zipfile
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any, BinaryIO, Mapping
14
+
15
+ PXXL_API_BASE_URL = "https://gateway.pxxl.app/api/v3"
16
+ MAX_DEPLOY_FILES = 12000
17
+ MAX_DEPLOY_SOURCE_BYTES = 220 * 1024 * 1024
18
+
19
+
20
+ class PxxlAPIError(RuntimeError):
21
+ def __init__(self, status_code: int, message: str, body: bytes):
22
+ super().__init__(f"pxxl: request failed with {status_code}: {message}")
23
+ self.status_code = status_code
24
+ self.message = message
25
+ self.body = body
26
+
27
+
28
+ @dataclass
29
+ class PxxlClient:
30
+ api_key: str
31
+ team_id: str | None = None
32
+ timeout: float = 60.0
33
+
34
+ def __post_init__(self) -> None:
35
+ self.api_key = (self.api_key or "").strip()
36
+ if not self.api_key:
37
+ raise ValueError("pxxl: api_key is required")
38
+ self.team_id = (self.team_id or "").strip() or None
39
+ self.base_url = PXXL_API_BASE_URL
40
+
41
+ def summary(self) -> dict[str, Any]:
42
+ return self._request("GET", "/cdn/summary")["data"]
43
+
44
+ def list_assets(self, **params: Any) -> dict[str, Any]:
45
+ return self._request("GET", "/cdn/assets" + _query(params))
46
+
47
+ def upload_asset(
48
+ self,
49
+ *,
50
+ file_path: str | os.PathLike[str] | None = None,
51
+ file: BinaryIO | bytes | None = None,
52
+ file_name: str | None = None,
53
+ visibility: str = "public",
54
+ kind: str = "file",
55
+ project_id: str | None = None,
56
+ deployment_id: str | None = None,
57
+ ) -> dict[str, Any]:
58
+ payload, inferred_name = _read_upload_file(file_path=file_path, file=file, file_name=file_name)
59
+ fields = {
60
+ "visibility": visibility,
61
+ "kind": kind,
62
+ "projectId": project_id,
63
+ "deploymentId": deployment_id,
64
+ }
65
+ body, content_type = _multipart(fields, "file", inferred_name, payload)
66
+ return self._request("POST", "/cdn/assets", body=body, content_type=content_type)["asset"]
67
+
68
+ def download_asset(self, asset_id: str) -> bytes:
69
+ return self._raw("GET", f"/cdn/assets/{_escape(asset_id)}/download")
70
+
71
+ def delete_asset(self, asset_id: str) -> None:
72
+ self._request("DELETE", f"/cdn/assets/{_escape(asset_id)}")
73
+
74
+ def list_tlds(self) -> dict[str, Any]:
75
+ return self._request("GET", "/domains/tlds")
76
+
77
+ def popular_tlds(self) -> dict[str, Any]:
78
+ return self._request("GET", "/domains/tlds/popular")
79
+
80
+ def search_tlds(self, query: str) -> dict[str, Any]:
81
+ return self._request("GET", f"/domains/tlds/search?q={urllib.parse.quote(query)}")
82
+
83
+ def search_domains(self, query: str, type: str | None = None) -> dict[str, Any]:
84
+ return self._request("POST", "/domains/search", json_body={"query": query, "type": type})
85
+
86
+ def list_domains(self, team_id: str | None = None) -> dict[str, Any]:
87
+ return self._request("GET", "/cli/domains" + self._team_query(team_id))
88
+
89
+ def domain_stats(self, domain: str, timeframe: str | None = None, team_id: str | None = None) -> dict[str, Any]:
90
+ params = {"timeframe": timeframe, "teamId": team_id or self.team_id}
91
+ return self._request("GET", f"/cli/domains/{_escape(domain)}/stats" + _query(params))
92
+
93
+ def check_domain(self, domain: str, team_id: str | None = None) -> dict[str, Any]:
94
+ return self._request("GET", f"/cli/domains/{_escape(domain)}/check" + self._team_query(team_id))
95
+
96
+ def connect_domain(self, domain: str, project_id: str, alias: bool = False, team_id: str | None = None) -> dict[str, Any]:
97
+ return self._request(
98
+ "POST",
99
+ "/cli/domains" + self._team_query(team_id),
100
+ json_body={"domain": domain, "projectId": project_id, "alias": alias},
101
+ )
102
+
103
+ def verify_domain_record(self, domain: str, project_id: str, team_id: str | None = None) -> dict[str, Any]:
104
+ selected_team = team_id or self.team_id
105
+ return self._request(
106
+ "POST",
107
+ "/cli/domains/checkrecord" + self._team_query(selected_team),
108
+ json_body={"domain": domain, "projectId": project_id, "teamId": selected_team},
109
+ )
110
+
111
+ def get_domain(self, domain_id: str, team_id: str | None = None) -> dict[str, Any]:
112
+ return self._request("GET", f"/cli/domains/{_escape(domain_id)}" + self._team_query(team_id))
113
+
114
+ def update_domain(self, domain_id: str, settings: Mapping[str, Any], team_id: str | None = None) -> dict[str, Any]:
115
+ return self._request("PATCH", f"/cli/domains/{_escape(domain_id)}" + self._team_query(team_id), json_body=dict(settings))
116
+
117
+ def list_domain_dns_records(self, domain_id: str, team_id: str | None = None) -> dict[str, Any]:
118
+ return self._request("GET", f"/cli/domains/{_escape(domain_id)}/dns-records" + self._team_query(team_id))
119
+
120
+ def create_domain_dns_record(self, domain_id: str, record: Mapping[str, Any], team_id: str | None = None) -> dict[str, Any]:
121
+ return self._request("POST", f"/cli/domains/{_escape(domain_id)}/dns-records" + self._team_query(team_id), json_body=dict(record))
122
+
123
+ def update_domain_dns_records(self, domain_id: str, record: Mapping[str, Any], team_id: str | None = None) -> dict[str, Any]:
124
+ return self._request("PUT", f"/cli/domains/{_escape(domain_id)}/dns-records" + self._team_query(team_id), json_body=dict(record))
125
+
126
+ def delete_domain_dns_record(self, domain_id: str, record: Mapping[str, Any], team_id: str | None = None) -> dict[str, Any]:
127
+ return self._request("DELETE", f"/cli/domains/{_escape(domain_id)}/dns-records" + self._team_query(team_id), json_body=dict(record))
128
+
129
+ def activate_domain(self, domain_id: str, team_id: str | None = None) -> dict[str, Any]:
130
+ return self._request("POST", f"/cli/domains/{_escape(domain_id)}/activate" + self._team_query(team_id), json_body={})
131
+
132
+ def get_domain_zone_status(self, domain_id: str, team_id: str | None = None) -> dict[str, Any]:
133
+ return self._request("GET", f"/cli/domains/{_escape(domain_id)}/zone-status" + self._team_query(team_id))
134
+
135
+ def download_domain_certificate(self, domain_id: str, team_id: str | None = None) -> bytes:
136
+ return self._raw("GET", f"/cli/domains/{_escape(domain_id)}/certificate/download" + self._team_query(team_id))
137
+
138
+ def list_cron_jobs(self, team_id: str | None = None) -> dict[str, Any]:
139
+ return self._request("GET", "/cli/cronjobs" + self._team_query(team_id))
140
+
141
+ def create_cron_job(self, *, name: str, schedule: str, url: str, method: str = "GET", **kwargs: Any) -> dict[str, Any]:
142
+ body = {"name": name, "schedule": schedule, "url": url, "method": method, **kwargs}
143
+ team_id = body.pop("team_id", None) or body.pop("teamId", None)
144
+ return self._request("POST", "/cli/cronjobs" + self._team_query(team_id), json_body=body)
145
+
146
+ def get_cron_job(self, cron_job_id: str, team_id: str | None = None) -> dict[str, Any]:
147
+ return self._request("GET", f"/cli/cronjobs/{_escape(cron_job_id)}" + self._team_query(team_id))
148
+
149
+ def update_cron_job(self, cron_job_id: str, values: Mapping[str, Any], team_id: str | None = None) -> dict[str, Any]:
150
+ return self._request("PUT", f"/cli/cronjobs/{_escape(cron_job_id)}" + self._team_query(team_id), json_body=dict(values))
151
+
152
+ def delete_cron_job(self, cron_job_id: str, team_id: str | None = None) -> None:
153
+ self._request("DELETE", f"/cli/cronjobs/{_escape(cron_job_id)}" + self._team_query(team_id))
154
+
155
+ def start_cron_job(self, cron_job_id: str, team_id: str | None = None) -> dict[str, Any]:
156
+ return self._cron_action(cron_job_id, "start", team_id)
157
+
158
+ def stop_cron_job(self, cron_job_id: str, team_id: str | None = None) -> dict[str, Any]:
159
+ return self._cron_action(cron_job_id, "stop", team_id)
160
+
161
+ def trigger_cron_job(self, cron_job_id: str, team_id: str | None = None) -> dict[str, Any]:
162
+ return self._cron_action(cron_job_id, "trigger", team_id)
163
+
164
+ def list_cron_job_runs(self, cron_job_id: str, **params: Any) -> dict[str, Any]:
165
+ return self._request("GET", f"/cli/cronjobs/{_escape(cron_job_id)}/runs" + _query(params))
166
+
167
+ def validate_cron_schedule(self, schedule: str) -> dict[str, Any]:
168
+ return self._request("POST", "/cli/cronjobs/validate-schedule", json_body={"schedule": schedule})
169
+
170
+ def validate_cron_url(self, url: str) -> dict[str, Any]:
171
+ return self._request("POST", "/cli/cronjobs/validate-url", json_body={"url": url})
172
+
173
+ def deploy(
174
+ self,
175
+ *,
176
+ directory: str | os.PathLike[str] | None = None,
177
+ archive_path: str | os.PathLike[str] | None = None,
178
+ name: str | None = None,
179
+ project_id: str | None = None,
180
+ domain_choice: str | None = None,
181
+ **metadata: Any,
182
+ ) -> dict[str, Any]:
183
+ if archive_path:
184
+ archive = Path(archive_path).read_bytes()
185
+ file_name = Path(archive_path).name
186
+ else:
187
+ archive = create_project_zip(directory or ".")
188
+ file_name = "pxxl-source.zip"
189
+ fields = {
190
+ "name": name,
191
+ "projectId": project_id,
192
+ "domainChoice": domain_choice,
193
+ "environment": metadata.pop("environment", "production"),
194
+ "sourceShape": "clideploy",
195
+ "deploymentSource": "clideploy",
196
+ **{_camel(k): v for k, v in metadata.items()},
197
+ }
198
+ body, content_type = _multipart(fields, "file", file_name, archive)
199
+ return self._request("POST", "/projects/spacedrop", body=body, content_type=content_type)
200
+
201
+ def _cron_action(self, cron_job_id: str, action: str, team_id: str | None = None) -> dict[str, Any]:
202
+ return self._request("POST", f"/cli/cronjobs/{_escape(cron_job_id)}/{action}" + self._team_query(team_id), json_body={})
203
+
204
+ def _team_query(self, team_id: str | None = None) -> str:
205
+ return _query({"teamId": team_id or self.team_id})
206
+
207
+ def _request(
208
+ self,
209
+ method: str,
210
+ path: str,
211
+ *,
212
+ json_body: Mapping[str, Any] | None = None,
213
+ body: bytes | None = None,
214
+ content_type: str | None = None,
215
+ ) -> dict[str, Any]:
216
+ data = body
217
+ if json_body is not None:
218
+ data = json.dumps({k: v for k, v in json_body.items() if v is not None}).encode()
219
+ content_type = "application/json"
220
+ raw = self._raw(method, path, body=data, content_type=content_type)
221
+ if not raw:
222
+ return {}
223
+ return json.loads(raw.decode())
224
+
225
+ def _raw(self, method: str, path: str, *, body: bytes | None = None, content_type: str | None = None) -> bytes:
226
+ req = urllib.request.Request(self.base_url + path, data=body, method=method)
227
+ req.add_header("Authorization", f"Bearer {self.api_key}")
228
+ req.add_header("User-Agent", "pxxl-python-sdk/0.1")
229
+ if content_type:
230
+ req.add_header("Content-Type", content_type)
231
+ try:
232
+ with urllib.request.urlopen(req, timeout=self.timeout) as response:
233
+ return response.read()
234
+ except urllib.error.HTTPError as err:
235
+ payload = err.read()
236
+ message = _error_message(payload) or err.reason
237
+ raise PxxlAPIError(err.code, message, payload) from err
238
+
239
+
240
+ def create_project_zip(root: str | os.PathLike[str]) -> bytes:
241
+ root_path = Path(root).resolve()
242
+ files: list[Path] = []
243
+ total = 0
244
+ for file_path in sorted(root_path.rglob("*")):
245
+ if file_path.is_dir() or file_path.is_symlink():
246
+ continue
247
+ rel = file_path.relative_to(root_path).as_posix()
248
+ if _skip_deploy_path(rel) or _looks_sensitive(rel):
249
+ continue
250
+ total += file_path.stat().st_size
251
+ if total > MAX_DEPLOY_SOURCE_BYTES:
252
+ raise ValueError("pxxl: deploy archive exceeds 220MB source limit")
253
+ files.append(file_path)
254
+ if len(files) > MAX_DEPLOY_FILES:
255
+ raise ValueError("pxxl: deploy archive exceeds file count limit")
256
+ if not files:
257
+ raise ValueError("pxxl: no deployable files found")
258
+
259
+ import io
260
+
261
+ buffer = io.BytesIO()
262
+ with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
263
+ for file_path in files:
264
+ archive.write(file_path, file_path.relative_to(root_path).as_posix())
265
+ return buffer.getvalue()
266
+
267
+
268
+ def _read_upload_file(*, file_path: str | os.PathLike[str] | None, file: BinaryIO | bytes | None, file_name: str | None) -> tuple[bytes, str]:
269
+ if file_path:
270
+ path = Path(file_path)
271
+ return path.read_bytes(), file_name or path.name
272
+ if isinstance(file, bytes):
273
+ return file, file_name or "upload.bin"
274
+ if file is not None:
275
+ return file.read(), file_name or getattr(file, "name", "upload.bin")
276
+ raise ValueError("pxxl: upload requires file_path or file")
277
+
278
+
279
+ def _multipart(fields: Mapping[str, Any], file_field: str, file_name: str, file_bytes: bytes) -> tuple[bytes, str]:
280
+ boundary = "----pxxl-" + secrets.token_hex(16)
281
+ lines: list[bytes] = []
282
+ for key, value in fields.items():
283
+ if value is None or value == "":
284
+ continue
285
+ lines.extend([
286
+ f"--{boundary}\r\n".encode(),
287
+ f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode(),
288
+ str(value).encode(),
289
+ b"\r\n",
290
+ ])
291
+ content_type = mimetypes.guess_type(file_name)[0] or "application/octet-stream"
292
+ lines.extend([
293
+ f"--{boundary}\r\n".encode(),
294
+ f'Content-Disposition: form-data; name="{file_field}"; filename="{Path(file_name).name}"\r\n'.encode(),
295
+ f"Content-Type: {content_type}\r\n\r\n".encode(),
296
+ file_bytes,
297
+ b"\r\n",
298
+ f"--{boundary}--\r\n".encode(),
299
+ ])
300
+ return b"".join(lines), f"multipart/form-data; boundary={boundary}"
301
+
302
+
303
+ def _query(values: Mapping[str, Any]) -> str:
304
+ clean = {k: v for k, v in values.items() if v is not None and v != ""}
305
+ return "?" + urllib.parse.urlencode(clean) if clean else ""
306
+
307
+
308
+ def _escape(value: str) -> str:
309
+ return urllib.parse.quote(str(value), safe="")
310
+
311
+
312
+ def _error_message(payload: bytes) -> str:
313
+ try:
314
+ body = json.loads(payload.decode())
315
+ except Exception:
316
+ return ""
317
+ return str(body.get("message") or body.get("error") or "")
318
+
319
+
320
+ def _camel(value: str) -> str:
321
+ parts = value.split("_")
322
+ return parts[0] + "".join(part[:1].upper() + part[1:] for part in parts[1:])
323
+
324
+
325
+ def _skip_deploy_path(rel: str) -> bool:
326
+ first = rel.split("/", 1)[0]
327
+ if first in {".git", "node_modules", ".next", ".turbo", ".cache", "dist", "build", ".output", "__pycache__"}:
328
+ return True
329
+ base = Path(rel).name
330
+ return base == ".pxxlignore" or base.startswith(".env") or base.endswith(".log") or base == "pxxl-source.zip"
331
+
332
+
333
+ def _looks_sensitive(rel: str) -> bool:
334
+ lower = rel.lower()
335
+ return lower.endswith((".pem", ".key")) or "id_rsa" in lower or "service-account" in lower or "credentials.json" in lower
@@ -0,0 +1,21 @@
1
+ from pathlib import Path
2
+ import unittest
3
+
4
+ from pxxl import PxxlClient, create_project_zip
5
+
6
+
7
+ class PxxlClientTests(unittest.TestCase):
8
+ def test_client_requires_api_key(self):
9
+ with self.assertRaisesRegex(ValueError, "api_key is required"):
10
+ PxxlClient(api_key="")
11
+
12
+ def test_create_project_zip_skips_secrets(self):
13
+ import tempfile
14
+
15
+ with tempfile.TemporaryDirectory() as tmp:
16
+ tmp_path = Path(tmp)
17
+ (tmp_path / "main.py").write_text("print('hello')\n")
18
+ (tmp_path / ".env").write_text("SECRET=1\n")
19
+ archive = create_project_zip(tmp_path)
20
+ self.assertIn(b"main.py", archive)
21
+ self.assertNotIn(b".env", archive)