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)