whispy-client 1.0.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.
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
.Python
|
|
7
|
+
*.egg
|
|
8
|
+
*.egg-info/
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
.eggs/
|
|
12
|
+
.env
|
|
13
|
+
.venv
|
|
14
|
+
env/
|
|
15
|
+
venv/
|
|
16
|
+
|
|
17
|
+
# Whispy
|
|
18
|
+
cache/
|
|
19
|
+
*.zip
|
|
20
|
+
|
|
21
|
+
# Testing
|
|
22
|
+
.pytest_cache/
|
|
23
|
+
.coverage
|
|
24
|
+
coverage.xml
|
|
25
|
+
htmlcov/
|
|
26
|
+
|
|
27
|
+
# Editors
|
|
28
|
+
.idea/
|
|
29
|
+
.vscode/
|
|
30
|
+
*.swp
|
|
31
|
+
*.swo
|
|
32
|
+
.DS_Store
|
|
33
|
+
|
|
34
|
+
# Docker
|
|
35
|
+
.dockerignore
|
|
36
|
+
|
|
37
|
+
# Cloudflare
|
|
38
|
+
.wrangler/
|
|
39
|
+
node_modules/
|
|
40
|
+
|
|
41
|
+
# Secrets
|
|
42
|
+
.env.local
|
|
43
|
+
secrets.json
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
.ruff_cache/
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: whispy-client
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Stream Python packages at runtime β the PyPI CDN client
|
|
5
|
+
Project-URL: Homepage, https://whispy.dev
|
|
6
|
+
Project-URL: Repository, https://github.com/Dark-Avenger-Reborn/Whispy
|
|
7
|
+
Project-URL: Documentation, https://whispy.dev/docs
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/Dark-Avenger-Reborn/Whispy/issues
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: cdn,dynamic-import,packages,pypi,runtime
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Classifier: Topic :: System :: Installation/Setup
|
|
23
|
+
Requires-Python: >=3.8
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# π Whispy β The Python Package CDN
|
|
27
|
+
|
|
28
|
+
> Stream Python packages at runtime. No `pip install`, no virtual envs, no environment setup.
|
|
29
|
+
> **The PyPI equivalent of unpkg.com / jsDelivr.**
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from whispy_client import remote
|
|
33
|
+
|
|
34
|
+
requests = remote("requests")
|
|
35
|
+
numpy = remote("numpy==1.26.4")
|
|
36
|
+
bs4 = remote("beautifulsoup4", module="bs4", deps=True)
|
|
37
|
+
|
|
38
|
+
print(requests.get("https://httpbin.org/get").status_code) # 200
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**Packages are streamed from PyPI through the Whispy CDN, SHA-256 verified, extracted to a
|
|
42
|
+
temporary directory, and imported at runtime. Nothing is permanently installed. Everything
|
|
43
|
+
disappears when your process exits.**
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Architecture
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
βββββββββββββββ HTTPS ββββββββββββββββββββ ββββββββββββββββ
|
|
51
|
+
β Your Script β βββββββββββΊ β Cloudflare Edge β βββΊβ Whispy β
|
|
52
|
+
β (client) β β (CDN cache) β β Origin Serverβ
|
|
53
|
+
βββββββββββββββ ββββββββββββββββββββ ββββββββ¬ββββββββ
|
|
54
|
+
β
|
|
55
|
+
ββββββββΌββββββββ
|
|
56
|
+
β PyPI API β
|
|
57
|
+
ββββββββββββββββ
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
- **Client** (`whispy_client`) β Zero-dependency installable. Computes PEP 425 tags, fetches the right wheel, extracts to tmpdir, imports.
|
|
61
|
+
- **CDN Edge** (Cloudflare Worker) β Caches versioned package zips with 1-year immutable TTL. Adds security headers, validates inputs.
|
|
62
|
+
- **Origin Server** (`server/app.py`) β Resolves packages, fetches from PyPI, verifies SHA-256, zips for serving, maintains disk cache.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Repository Structure
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
whispy/
|
|
70
|
+
βββ server/ # Whispy CDN origin server
|
|
71
|
+
β βββ app.py # Flask application
|
|
72
|
+
β βββ requirements.txt
|
|
73
|
+
β βββ Dockerfile
|
|
74
|
+
β
|
|
75
|
+
βββ client/ # whispy-client Python package
|
|
76
|
+
β βββ whispy_client/
|
|
77
|
+
β β βββ __init__.py # Public API: remote(), configure()
|
|
78
|
+
β β βββ core.py # Zero-dep implementation
|
|
79
|
+
β βββ pyproject.toml
|
|
80
|
+
β
|
|
81
|
+
βββ docs/ # whispy.dev documentation site
|
|
82
|
+
β βββ index.html
|
|
83
|
+
β
|
|
84
|
+
βββ deploy/ # Infrastructure configs
|
|
85
|
+
β βββ docker-compose.yml
|
|
86
|
+
β βββ cloudflare-worker.js # Cloudflare CDN edge layer
|
|
87
|
+
β βββ wrangler.toml
|
|
88
|
+
β
|
|
89
|
+
βββ .github/
|
|
90
|
+
βββ workflows/
|
|
91
|
+
βββ ci.yml # CI: test β build β publish β deploy
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Quick Start
|
|
97
|
+
|
|
98
|
+
### Use the hosted CDN
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pip install whispy-client
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from whispy_client import remote, configure
|
|
106
|
+
|
|
107
|
+
# Optional: enable dep resolution and verbose logging
|
|
108
|
+
configure(deps=True, verbose=True)
|
|
109
|
+
|
|
110
|
+
requests = remote("requests")
|
|
111
|
+
print(requests.get("https://httpbin.org/get").status_code)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Self-host
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# Clone
|
|
118
|
+
git clone https://github.com/Dark-Avenger-Reborn/Whispy
|
|
119
|
+
cd Whispy
|
|
120
|
+
|
|
121
|
+
# Run with Docker Compose
|
|
122
|
+
docker compose -f deploy/docker-compose.yml up -d
|
|
123
|
+
|
|
124
|
+
# Point your client at it
|
|
125
|
+
WHISPY_HOST=http://localhost:8000 python my_script.py
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Run server locally (dev)
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
cd server
|
|
132
|
+
pip install -r requirements.txt
|
|
133
|
+
python app.py --debug
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Client API
|
|
139
|
+
|
|
140
|
+
### `remote(package, *, module=None, version=None, deps=False, host=None)`
|
|
141
|
+
|
|
142
|
+
| Param | Description |
|
|
143
|
+
|-------|-------------|
|
|
144
|
+
| `package` | PyPI name, optionally with `==version` e.g. `"requests==2.31.0"` |
|
|
145
|
+
| `module` | Import name if different from package name (e.g. `module="bs4"`) |
|
|
146
|
+
| `version` | Explicit version, overrides embedded spec |
|
|
147
|
+
| `deps` | If `True`, resolve and fetch transitive dependencies |
|
|
148
|
+
| `host` | Per-call CDN URL override |
|
|
149
|
+
|
|
150
|
+
**Common name mismatches:**
|
|
151
|
+
```python
|
|
152
|
+
bs4 = remote("beautifulsoup4", module="bs4")
|
|
153
|
+
PIL = remote("pillow", module="PIL")
|
|
154
|
+
yaml = remote("pyyaml", module="yaml")
|
|
155
|
+
dateutil = remote("python-dateutil",module="dateutil")
|
|
156
|
+
cv2 = remote("opencv-python", module="cv2")
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### `configure(*, host=None, deps=None, verbose=None)`
|
|
160
|
+
|
|
161
|
+
Set global defaults. Can also use `WHISPY_HOST` env var.
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Server API
|
|
166
|
+
|
|
167
|
+
| Endpoint | Description |
|
|
168
|
+
|----------|-------------|
|
|
169
|
+
| `GET /get_package?name=X&tags=...&version=Y&deps=1` | Download package zip |
|
|
170
|
+
| `GET /metadata/<package>` | Package metadata without download |
|
|
171
|
+
| `GET /health` | Health check + cache stats |
|
|
172
|
+
| `GET /stats` | Cache statistics |
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Security
|
|
177
|
+
|
|
178
|
+
- **SHA-256 verification** β Every file verified against PyPI digests before serving
|
|
179
|
+
- **Blocklist** β Known typosquatted packages are rejected
|
|
180
|
+
- **Input validation** β Package names validated against `[A-Za-z0-9_.-]+`
|
|
181
|
+
- **Rate limiting** β 60 req/min per IP on `/get_package`
|
|
182
|
+
- **HTTPS enforced** β Cloudflare handles TLS termination
|
|
183
|
+
- **Immutable URLs** β Versioned package URLs are `Cache-Control: immutable`
|
|
184
|
+
- **Server never imports packages** β Only proxies/caches them
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## Deployment
|
|
189
|
+
|
|
190
|
+
### CI/CD (GitHub Actions)
|
|
191
|
+
|
|
192
|
+
The `.github/workflows/ci.yml` pipeline:
|
|
193
|
+
|
|
194
|
+
1. **Test** β server tests + client across Python 3.8β3.13, Linux/macOS/Windows
|
|
195
|
+
2. **Lint** β ruff
|
|
196
|
+
3. **Docker** β builds and pushes to GHCR on every `main` push
|
|
197
|
+
4. **PyPI** β publishes `whispy-client` on version tags (`v*`)
|
|
198
|
+
5. **Cloudflare** β deploys the Worker on `main`
|
|
199
|
+
|
|
200
|
+
### Required secrets
|
|
201
|
+
|
|
202
|
+
| Secret | Description |
|
|
203
|
+
|--------|-------------|
|
|
204
|
+
| `CF_API_TOKEN` | Cloudflare API token with Workers:Edit permission |
|
|
205
|
+
|
|
206
|
+
### Environment vars (server)
|
|
207
|
+
|
|
208
|
+
| Var | Default | Description |
|
|
209
|
+
|-----|---------|-------------|
|
|
210
|
+
| `WHISPY_CACHE_DIR` | `./cache` | Disk cache directory |
|
|
211
|
+
| `WHISPY_MAX_CACHE_MB` | `2048` | Max cache size in MB |
|
|
212
|
+
| `REDIS_URL` | `memory://` | Redis URL for distributed rate limiting |
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## Roadmap
|
|
217
|
+
|
|
218
|
+
- [ ] Conflict-aware dependency resolver (full PubGrub/resolvelib integration)
|
|
219
|
+
- [ ] `whispy lock` CLI β generate a lockfile for reproducible scripts
|
|
220
|
+
- [ ] Browser / Pyodide support
|
|
221
|
+
- [ ] Package usage analytics dashboard
|
|
222
|
+
- [ ] Webhook notifications for new package versions
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## License
|
|
227
|
+
|
|
228
|
+
MIT β see [LICENSE](LICENSE).
|
|
229
|
+
|
|
230
|
+
> Packages are sourced from [PyPI](https://pypi.org) and served under their original licenses.
|
|
231
|
+
> Whispy does not host or redistribute package source code β it proxies directly from PyPI's CDN.
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# π Whispy β The Python Package CDN
|
|
2
|
+
|
|
3
|
+
> Stream Python packages at runtime. No `pip install`, no virtual envs, no environment setup.
|
|
4
|
+
> **The PyPI equivalent of unpkg.com / jsDelivr.**
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
from whispy_client import remote
|
|
8
|
+
|
|
9
|
+
requests = remote("requests")
|
|
10
|
+
numpy = remote("numpy==1.26.4")
|
|
11
|
+
bs4 = remote("beautifulsoup4", module="bs4", deps=True)
|
|
12
|
+
|
|
13
|
+
print(requests.get("https://httpbin.org/get").status_code) # 200
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
**Packages are streamed from PyPI through the Whispy CDN, SHA-256 verified, extracted to a
|
|
17
|
+
temporary directory, and imported at runtime. Nothing is permanently installed. Everything
|
|
18
|
+
disappears when your process exits.**
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Architecture
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
βββββββββββββββ HTTPS ββββββββββββββββββββ ββββββββββββββββ
|
|
26
|
+
β Your Script β βββββββββββΊ β Cloudflare Edge β βββΊβ Whispy β
|
|
27
|
+
β (client) β β (CDN cache) β β Origin Serverβ
|
|
28
|
+
βββββββββββββββ ββββββββββββββββββββ ββββββββ¬ββββββββ
|
|
29
|
+
β
|
|
30
|
+
ββββββββΌββββββββ
|
|
31
|
+
β PyPI API β
|
|
32
|
+
ββββββββββββββββ
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- **Client** (`whispy_client`) β Zero-dependency installable. Computes PEP 425 tags, fetches the right wheel, extracts to tmpdir, imports.
|
|
36
|
+
- **CDN Edge** (Cloudflare Worker) β Caches versioned package zips with 1-year immutable TTL. Adds security headers, validates inputs.
|
|
37
|
+
- **Origin Server** (`server/app.py`) β Resolves packages, fetches from PyPI, verifies SHA-256, zips for serving, maintains disk cache.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Repository Structure
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
whispy/
|
|
45
|
+
βββ server/ # Whispy CDN origin server
|
|
46
|
+
β βββ app.py # Flask application
|
|
47
|
+
β βββ requirements.txt
|
|
48
|
+
β βββ Dockerfile
|
|
49
|
+
β
|
|
50
|
+
βββ client/ # whispy-client Python package
|
|
51
|
+
β βββ whispy_client/
|
|
52
|
+
β β βββ __init__.py # Public API: remote(), configure()
|
|
53
|
+
β β βββ core.py # Zero-dep implementation
|
|
54
|
+
β βββ pyproject.toml
|
|
55
|
+
β
|
|
56
|
+
βββ docs/ # whispy.dev documentation site
|
|
57
|
+
β βββ index.html
|
|
58
|
+
β
|
|
59
|
+
βββ deploy/ # Infrastructure configs
|
|
60
|
+
β βββ docker-compose.yml
|
|
61
|
+
β βββ cloudflare-worker.js # Cloudflare CDN edge layer
|
|
62
|
+
β βββ wrangler.toml
|
|
63
|
+
β
|
|
64
|
+
βββ .github/
|
|
65
|
+
βββ workflows/
|
|
66
|
+
βββ ci.yml # CI: test β build β publish β deploy
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Quick Start
|
|
72
|
+
|
|
73
|
+
### Use the hosted CDN
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install whispy-client
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from whispy_client import remote, configure
|
|
81
|
+
|
|
82
|
+
# Optional: enable dep resolution and verbose logging
|
|
83
|
+
configure(deps=True, verbose=True)
|
|
84
|
+
|
|
85
|
+
requests = remote("requests")
|
|
86
|
+
print(requests.get("https://httpbin.org/get").status_code)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Self-host
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# Clone
|
|
93
|
+
git clone https://github.com/Dark-Avenger-Reborn/Whispy
|
|
94
|
+
cd Whispy
|
|
95
|
+
|
|
96
|
+
# Run with Docker Compose
|
|
97
|
+
docker compose -f deploy/docker-compose.yml up -d
|
|
98
|
+
|
|
99
|
+
# Point your client at it
|
|
100
|
+
WHISPY_HOST=http://localhost:8000 python my_script.py
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Run server locally (dev)
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
cd server
|
|
107
|
+
pip install -r requirements.txt
|
|
108
|
+
python app.py --debug
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Client API
|
|
114
|
+
|
|
115
|
+
### `remote(package, *, module=None, version=None, deps=False, host=None)`
|
|
116
|
+
|
|
117
|
+
| Param | Description |
|
|
118
|
+
|-------|-------------|
|
|
119
|
+
| `package` | PyPI name, optionally with `==version` e.g. `"requests==2.31.0"` |
|
|
120
|
+
| `module` | Import name if different from package name (e.g. `module="bs4"`) |
|
|
121
|
+
| `version` | Explicit version, overrides embedded spec |
|
|
122
|
+
| `deps` | If `True`, resolve and fetch transitive dependencies |
|
|
123
|
+
| `host` | Per-call CDN URL override |
|
|
124
|
+
|
|
125
|
+
**Common name mismatches:**
|
|
126
|
+
```python
|
|
127
|
+
bs4 = remote("beautifulsoup4", module="bs4")
|
|
128
|
+
PIL = remote("pillow", module="PIL")
|
|
129
|
+
yaml = remote("pyyaml", module="yaml")
|
|
130
|
+
dateutil = remote("python-dateutil",module="dateutil")
|
|
131
|
+
cv2 = remote("opencv-python", module="cv2")
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### `configure(*, host=None, deps=None, verbose=None)`
|
|
135
|
+
|
|
136
|
+
Set global defaults. Can also use `WHISPY_HOST` env var.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Server API
|
|
141
|
+
|
|
142
|
+
| Endpoint | Description |
|
|
143
|
+
|----------|-------------|
|
|
144
|
+
| `GET /get_package?name=X&tags=...&version=Y&deps=1` | Download package zip |
|
|
145
|
+
| `GET /metadata/<package>` | Package metadata without download |
|
|
146
|
+
| `GET /health` | Health check + cache stats |
|
|
147
|
+
| `GET /stats` | Cache statistics |
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Security
|
|
152
|
+
|
|
153
|
+
- **SHA-256 verification** β Every file verified against PyPI digests before serving
|
|
154
|
+
- **Blocklist** β Known typosquatted packages are rejected
|
|
155
|
+
- **Input validation** β Package names validated against `[A-Za-z0-9_.-]+`
|
|
156
|
+
- **Rate limiting** β 60 req/min per IP on `/get_package`
|
|
157
|
+
- **HTTPS enforced** β Cloudflare handles TLS termination
|
|
158
|
+
- **Immutable URLs** β Versioned package URLs are `Cache-Control: immutable`
|
|
159
|
+
- **Server never imports packages** β Only proxies/caches them
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Deployment
|
|
164
|
+
|
|
165
|
+
### CI/CD (GitHub Actions)
|
|
166
|
+
|
|
167
|
+
The `.github/workflows/ci.yml` pipeline:
|
|
168
|
+
|
|
169
|
+
1. **Test** β server tests + client across Python 3.8β3.13, Linux/macOS/Windows
|
|
170
|
+
2. **Lint** β ruff
|
|
171
|
+
3. **Docker** β builds and pushes to GHCR on every `main` push
|
|
172
|
+
4. **PyPI** β publishes `whispy-client` on version tags (`v*`)
|
|
173
|
+
5. **Cloudflare** β deploys the Worker on `main`
|
|
174
|
+
|
|
175
|
+
### Required secrets
|
|
176
|
+
|
|
177
|
+
| Secret | Description |
|
|
178
|
+
|--------|-------------|
|
|
179
|
+
| `CF_API_TOKEN` | Cloudflare API token with Workers:Edit permission |
|
|
180
|
+
|
|
181
|
+
### Environment vars (server)
|
|
182
|
+
|
|
183
|
+
| Var | Default | Description |
|
|
184
|
+
|-----|---------|-------------|
|
|
185
|
+
| `WHISPY_CACHE_DIR` | `./cache` | Disk cache directory |
|
|
186
|
+
| `WHISPY_MAX_CACHE_MB` | `2048` | Max cache size in MB |
|
|
187
|
+
| `REDIS_URL` | `memory://` | Redis URL for distributed rate limiting |
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## Roadmap
|
|
192
|
+
|
|
193
|
+
- [ ] Conflict-aware dependency resolver (full PubGrub/resolvelib integration)
|
|
194
|
+
- [ ] `whispy lock` CLI β generate a lockfile for reproducible scripts
|
|
195
|
+
- [ ] Browser / Pyodide support
|
|
196
|
+
- [ ] Package usage analytics dashboard
|
|
197
|
+
- [ ] Webhook notifications for new package versions
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## License
|
|
202
|
+
|
|
203
|
+
MIT β see [LICENSE](LICENSE).
|
|
204
|
+
|
|
205
|
+
> Packages are sourced from [PyPI](https://pypi.org) and served under their original licenses.
|
|
206
|
+
> Whispy does not host or redistribute package source code β it proxies directly from PyPI's CDN.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "whispy-client"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Stream Python packages at runtime β the PyPI CDN client"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
dependencies = [] # zero runtime dependencies β intentional
|
|
13
|
+
|
|
14
|
+
keywords = ["cdn", "packages", "dynamic-import", "pypi", "runtime"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.8",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Software Development :: Libraries",
|
|
27
|
+
"Topic :: System :: Installation/Setup",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://whispy.dev"
|
|
32
|
+
Repository = "https://github.com/Dark-Avenger-Reborn/Whispy"
|
|
33
|
+
Documentation = "https://whispy.dev/docs"
|
|
34
|
+
"Bug Tracker" = "https://github.com/Dark-Avenger-Reborn/Whispy/issues"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["whispy_client"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
whispy_client β Stream Python packages at runtime, no pip install required.
|
|
3
|
+
|
|
4
|
+
from whispy_client import remote
|
|
5
|
+
|
|
6
|
+
requests = remote("requests")
|
|
7
|
+
numpy = remote("numpy==1.26.4")
|
|
8
|
+
bs4 = remote("beautifulsoup4", module="bs4", deps=True)
|
|
9
|
+
"""
|
|
10
|
+
from .core import remote, configure, WhispyError, __version__
|
|
11
|
+
|
|
12
|
+
__all__ = ["remote", "configure", "WhispyError", "__version__"]
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""
|
|
2
|
+
whispy_client β Zero-dependency Python client for the Whispy CDN.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from whispy_client import remote
|
|
6
|
+
|
|
7
|
+
requests = remote("requests")
|
|
8
|
+
numpy = remote("numpy==1.26.4")
|
|
9
|
+
bs4 = remote("beautifulsoup4", module="bs4", deps=True)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib
|
|
15
|
+
import io
|
|
16
|
+
import json
|
|
17
|
+
import platform
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.request
|
|
23
|
+
import zipfile
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
__version__ = "1.0.0"
|
|
27
|
+
__all__ = ["remote", "configure", "WhispyError"]
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Default CDN host β users can override via configure() or WHISPY_HOST env var
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
import os as _os
|
|
33
|
+
_DEFAULT_HOST = _os.environ.get("WHISPY_HOST", "https://cdn.whispy.dev")
|
|
34
|
+
|
|
35
|
+
_config = {
|
|
36
|
+
"host": _DEFAULT_HOST,
|
|
37
|
+
"deps": False,
|
|
38
|
+
"verbose": False,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Tracks live TemporaryDirectory objects so they stay alive for the process
|
|
42
|
+
_live_tmpdirs: list[tempfile.TemporaryDirectory] = []
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class WhispyError(RuntimeError):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Public API
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def configure(
|
|
54
|
+
*,
|
|
55
|
+
host: Optional[str] = None,
|
|
56
|
+
deps: Optional[bool] = None,
|
|
57
|
+
verbose: Optional[bool] = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""
|
|
60
|
+
Configure Whispy globally.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
host: CDN base URL, e.g. "http://localhost:5000" for local dev.
|
|
64
|
+
deps: If True, automatically fetch dependencies alongside packages.
|
|
65
|
+
verbose: If True, print progress messages.
|
|
66
|
+
"""
|
|
67
|
+
if host is not None:
|
|
68
|
+
_config["host"] = host.rstrip("/")
|
|
69
|
+
if deps is not None:
|
|
70
|
+
_config["deps"] = deps
|
|
71
|
+
if verbose is not None:
|
|
72
|
+
_config["verbose"] = verbose
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def remote(
|
|
76
|
+
package: str,
|
|
77
|
+
*,
|
|
78
|
+
module: Optional[str] = None,
|
|
79
|
+
version: Optional[str] = None,
|
|
80
|
+
deps: Optional[bool] = None,
|
|
81
|
+
host: Optional[str] = None,
|
|
82
|
+
) -> object:
|
|
83
|
+
"""
|
|
84
|
+
Import a package from the Whispy CDN at runtime.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
package: Package name as on PyPI, optionally with version spec
|
|
88
|
+
e.g. "requests" or "requests==2.31.0" or "requests>=2.28"
|
|
89
|
+
module: Import name if different from package name.
|
|
90
|
+
e.g. remote("beautifulsoup4", module="bs4")
|
|
91
|
+
version: Explicit version string, overrides any spec in package name.
|
|
92
|
+
deps: Fetch dependencies too. Overrides global configure() setting.
|
|
93
|
+
host: CDN host override for this call only.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
The imported module object.
|
|
97
|
+
|
|
98
|
+
Example:
|
|
99
|
+
requests = remote("requests")
|
|
100
|
+
print(requests.get("https://httpbin.org/get").status_code)
|
|
101
|
+
"""
|
|
102
|
+
pkg_name, pkg_version = _parse_package_spec(package)
|
|
103
|
+
resolved_version = version or pkg_version
|
|
104
|
+
resolved_module = module or pkg_name
|
|
105
|
+
resolved_host = (host or _config["host"]).rstrip("/")
|
|
106
|
+
resolved_deps = _config["deps"] if deps is None else deps
|
|
107
|
+
verbose = _config["verbose"]
|
|
108
|
+
|
|
109
|
+
# Return from sys.modules if already loaded
|
|
110
|
+
if resolved_module in sys.modules:
|
|
111
|
+
return sys.modules[resolved_module]
|
|
112
|
+
|
|
113
|
+
if verbose:
|
|
114
|
+
print(f"π Whispy: fetching {pkg_name}" + (f"=={resolved_version}" if resolved_version else ""))
|
|
115
|
+
|
|
116
|
+
tags = _compute_tags()
|
|
117
|
+
params = {
|
|
118
|
+
"name": pkg_name,
|
|
119
|
+
"tags": ",".join(tags),
|
|
120
|
+
"deps": "1" if resolved_deps else "0",
|
|
121
|
+
}
|
|
122
|
+
if resolved_version:
|
|
123
|
+
params["version"] = resolved_version
|
|
124
|
+
|
|
125
|
+
url = f"{resolved_host}/get_package?" + urllib.parse.urlencode(params)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
data = _fetch_bytes(url, verbose=verbose)
|
|
129
|
+
except urllib.error.HTTPError as e:
|
|
130
|
+
body = e.read().decode(errors="replace")
|
|
131
|
+
try:
|
|
132
|
+
msg = json.loads(body).get("error", body)
|
|
133
|
+
except Exception:
|
|
134
|
+
msg = body
|
|
135
|
+
raise WhispyError(f"Whispy CDN error for '{pkg_name}': {msg}") from e
|
|
136
|
+
except Exception as e:
|
|
137
|
+
raise WhispyError(f"Could not reach Whispy CDN at {resolved_host}: {e}") from e
|
|
138
|
+
|
|
139
|
+
# Extract into a TemporaryDirectory that lives for the process lifetime
|
|
140
|
+
tmpdir = tempfile.TemporaryDirectory(prefix="whispy_")
|
|
141
|
+
_live_tmpdirs.append(tmpdir)
|
|
142
|
+
|
|
143
|
+
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
|
144
|
+
zf.extractall(tmpdir.name)
|
|
145
|
+
|
|
146
|
+
if tmpdir.name not in sys.path:
|
|
147
|
+
sys.path.insert(0, tmpdir.name)
|
|
148
|
+
|
|
149
|
+
if verbose:
|
|
150
|
+
print(f"β
Whispy: imported {resolved_module} from {tmpdir.name}")
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
return importlib.import_module(resolved_module)
|
|
154
|
+
except ModuleNotFoundError as e:
|
|
155
|
+
raise WhispyError(
|
|
156
|
+
f"Package '{pkg_name}' was downloaded but module '{resolved_module}' could not be imported. "
|
|
157
|
+
f"Try setting module= explicitly. Original error: {e}"
|
|
158
|
+
) from e
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# Internal helpers
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def _parse_package_spec(spec: str) -> tuple[str, Optional[str]]:
|
|
166
|
+
"""
|
|
167
|
+
Parse "requests==2.31.0" β ("requests", "2.31.0")
|
|
168
|
+
Parse "requests>=2.28" β ("requests", None) (range specs unsupported client-side)
|
|
169
|
+
Parse "requests" β ("requests", None)
|
|
170
|
+
"""
|
|
171
|
+
m = re.match(r'^([A-Za-z0-9_.\-]+)==([A-Za-z0-9._]+)$', spec.strip())
|
|
172
|
+
if m:
|
|
173
|
+
return m.group(1), m.group(2)
|
|
174
|
+
m = re.match(r'^([A-Za-z0-9_.\-]+)', spec.strip())
|
|
175
|
+
if m:
|
|
176
|
+
return m.group(1), None
|
|
177
|
+
raise WhispyError(f"Cannot parse package spec: '{spec}'")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _fetch_bytes(url: str, verbose: bool = False) -> bytes:
|
|
181
|
+
if verbose:
|
|
182
|
+
print(f" β GET {url}")
|
|
183
|
+
req = urllib.request.Request(
|
|
184
|
+
url,
|
|
185
|
+
headers={"User-Agent": f"whispy-client/{__version__} Python/{sys.version.split()[0]}"},
|
|
186
|
+
)
|
|
187
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
188
|
+
return resp.read()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _compute_tags() -> list[str]:
|
|
192
|
+
"""
|
|
193
|
+
Generate the ordered list of PEP 425 compatibility tags for this interpreter.
|
|
194
|
+
Most-specific first (matching pip's own ordering).
|
|
195
|
+
No external dependencies β pure stdlib.
|
|
196
|
+
"""
|
|
197
|
+
impl = platform.python_implementation()
|
|
198
|
+
vi = sys.version_info
|
|
199
|
+
machine = platform.machine().lower()
|
|
200
|
+
system = platform.system()
|
|
201
|
+
|
|
202
|
+
if impl == "CPython":
|
|
203
|
+
interp_base = f"cp{vi.major}{vi.minor}"
|
|
204
|
+
abi_base = f"cp{vi.major}{vi.minor}"
|
|
205
|
+
abi_tags = [abi_base, "abi3", "none"]
|
|
206
|
+
interp_tags = [interp_base, f"cp{vi.major}", "py3", f"py{vi.major}{vi.minor}"]
|
|
207
|
+
elif impl == "PyPy":
|
|
208
|
+
interp_base = f"pp{vi.major}{vi.minor}"
|
|
209
|
+
abi_base = f"pypy{vi.major}{vi.minor}"
|
|
210
|
+
abi_tags = [abi_base, "none"]
|
|
211
|
+
interp_tags = [interp_base, "py3"]
|
|
212
|
+
else:
|
|
213
|
+
interp_base = f"cp{vi.major}{vi.minor}"
|
|
214
|
+
abi_tags = ["none"]
|
|
215
|
+
interp_tags = ["py3"]
|
|
216
|
+
|
|
217
|
+
platform_tags = _platform_tags(system, machine, vi)
|
|
218
|
+
|
|
219
|
+
tags: list[str] = []
|
|
220
|
+
# Specific tags first (interp + abi + platform)
|
|
221
|
+
for interp in interp_tags:
|
|
222
|
+
for abi in abi_tags:
|
|
223
|
+
for plat in platform_tags:
|
|
224
|
+
tags.append(f"{interp}-{abi}-{plat}")
|
|
225
|
+
|
|
226
|
+
# Pure-python fallbacks
|
|
227
|
+
for interp in interp_tags:
|
|
228
|
+
if f"{interp}-none-any" not in tags:
|
|
229
|
+
tags.append(f"{interp}-none-any")
|
|
230
|
+
tags.append("py3-none-any")
|
|
231
|
+
|
|
232
|
+
# Deduplicate preserving order
|
|
233
|
+
seen: set[str] = set()
|
|
234
|
+
result: list[str] = []
|
|
235
|
+
for t in tags:
|
|
236
|
+
if t not in seen:
|
|
237
|
+
seen.add(t)
|
|
238
|
+
result.append(t)
|
|
239
|
+
return result
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _platform_tags(system: str, machine: str, vi) -> list[str]:
|
|
243
|
+
if system == "Linux":
|
|
244
|
+
# Support manylinux + musllinux variants
|
|
245
|
+
tags = []
|
|
246
|
+
manylinux_versions = [
|
|
247
|
+
(2, 35), (2, 34), (2, 33), (2, 32), (2, 31), (2, 30),
|
|
248
|
+
(2, 29), (2, 28), (2, 27), (2, 26), (2, 17), (2, 12), (2, 5),
|
|
249
|
+
]
|
|
250
|
+
arch_map = {
|
|
251
|
+
"x86_64": "x86_64",
|
|
252
|
+
"aarch64": "aarch64",
|
|
253
|
+
"arm64": "aarch64",
|
|
254
|
+
"armv7l": "armv7l",
|
|
255
|
+
"i686": "i686",
|
|
256
|
+
"ppc64le": "ppc64le",
|
|
257
|
+
"s390x": "s390x",
|
|
258
|
+
}
|
|
259
|
+
arch = arch_map.get(machine, machine)
|
|
260
|
+
for major, minor in manylinux_versions:
|
|
261
|
+
tags.append(f"manylinux_{major}_{minor}_{arch}")
|
|
262
|
+
tags.append(f"manylinux2014_{arch}")
|
|
263
|
+
tags.append(f"linux_{arch}")
|
|
264
|
+
return tags
|
|
265
|
+
|
|
266
|
+
elif system == "Darwin":
|
|
267
|
+
# macOS: detect arm64 vs x86_64
|
|
268
|
+
if machine in ("arm64", "aarch64"):
|
|
269
|
+
archs = ["arm64", "universal2"]
|
|
270
|
+
else:
|
|
271
|
+
archs = ["x86_64", "universal2", "intel"]
|
|
272
|
+
|
|
273
|
+
mac_ver = platform.mac_ver()[0]
|
|
274
|
+
if mac_ver:
|
|
275
|
+
try:
|
|
276
|
+
parts = mac_ver.split(".")
|
|
277
|
+
maj, mn = int(parts[0]), int(parts[1]) if len(parts) > 1 else 0
|
|
278
|
+
except ValueError:
|
|
279
|
+
maj, mn = 14, 0
|
|
280
|
+
else:
|
|
281
|
+
maj, mn = 14, 0
|
|
282
|
+
|
|
283
|
+
tags = []
|
|
284
|
+
for arch in archs:
|
|
285
|
+
for minor in range(mn, -1, -1):
|
|
286
|
+
tags.append(f"macosx_{maj}_{minor}_{arch}")
|
|
287
|
+
for older_major in range(maj - 1, 9, -1):
|
|
288
|
+
tags.append(f"macosx_{older_major}_0_{arch}")
|
|
289
|
+
return tags
|
|
290
|
+
|
|
291
|
+
elif system == "Windows":
|
|
292
|
+
if "64" in machine or machine == "amd64":
|
|
293
|
+
return ["win_amd64", "win32"]
|
|
294
|
+
elif "arm" in machine:
|
|
295
|
+
return ["win_arm64", "win32"]
|
|
296
|
+
else:
|
|
297
|
+
return ["win32"]
|
|
298
|
+
|
|
299
|
+
return ["any"]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ---------------------------------------------------------------------------
|
|
303
|
+
# urllib.parse needed for _fetch_bytes params
|
|
304
|
+
# ---------------------------------------------------------------------------
|
|
305
|
+
import urllib.parse # noqa: E402 (already imported via urllib.request chain)
|