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 +5 -0
- pxxl-0.1.0/PKG-INFO +79 -0
- pxxl-0.1.0/README.md +57 -0
- pxxl-0.1.0/pyproject.toml +32 -0
- pxxl-0.1.0/src/pxxl/__init__.py +3 -0
- pxxl-0.1.0/src/pxxl/client.py +335 -0
- pxxl-0.1.0/tests/test_client.py +21 -0
pxxl-0.1.0/.gitignore
ADDED
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,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)
|