auvux 0.1.0a1__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.
- auvux-0.1.0a1/LICENSE +21 -0
- auvux-0.1.0a1/PKG-INFO +148 -0
- auvux-0.1.0a1/README.md +117 -0
- auvux-0.1.0a1/auvux/__init__.py +30 -0
- auvux-0.1.0a1/auvux/auth.py +216 -0
- auvux-0.1.0a1/auvux/canvas.py +319 -0
- auvux-0.1.0a1/auvux/cli.py +73 -0
- auvux-0.1.0a1/auvux/credentials.py +67 -0
- auvux-0.1.0a1/auvux/lanes.py +346 -0
- auvux-0.1.0a1/auvux/markers.py +136 -0
- auvux-0.1.0a1/auvux/plots.py +227 -0
- auvux-0.1.0a1/auvux/transport.py +432 -0
- auvux-0.1.0a1/auvux/writers.py +112 -0
- auvux-0.1.0a1/auvux.egg-info/PKG-INFO +148 -0
- auvux-0.1.0a1/auvux.egg-info/SOURCES.txt +19 -0
- auvux-0.1.0a1/auvux.egg-info/dependency_links.txt +1 -0
- auvux-0.1.0a1/auvux.egg-info/entry_points.txt +2 -0
- auvux-0.1.0a1/auvux.egg-info/requires.txt +4 -0
- auvux-0.1.0a1/auvux.egg-info/top_level.txt +1 -0
- auvux-0.1.0a1/pyproject.toml +44 -0
- auvux-0.1.0a1/setup.cfg +4 -0
auvux-0.1.0a1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Auvux
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
auvux-0.1.0a1/PKG-INFO
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: auvux
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Annotate faster. See deeper. Sound smarter.
|
|
5
|
+
Author-email: Auvux <peter@dreamteam.nl>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://inference.auvux.com
|
|
8
|
+
Project-URL: Repository, https://github.com/auvux/auvux-py
|
|
9
|
+
Project-URL: Issues, https://github.com/auvux/auvux-py/issues
|
|
10
|
+
Keywords: webrtc,jupyter,visualization,inference,audio
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
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
|
+
Classifier: Framework :: Jupyter
|
|
21
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: aiortc>=1.9.0
|
|
27
|
+
Requires-Dist: websockets>=12.0
|
|
28
|
+
Requires-Dist: numpy>=1.24
|
|
29
|
+
Requires-Dist: soundfile>=0.12
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
<p align="center">
|
|
33
|
+
<img src="https://raw.githubusercontent.com/auvux/auvux-py/main/assets/logo.png" alt="auvux" width="120" />
|
|
34
|
+
</p>
|
|
35
|
+
|
|
36
|
+
<h1 align="center">auvux</h1>
|
|
37
|
+
|
|
38
|
+
<p align="center"><em>Annotate faster. See deeper. Sound smarter.</em></p>
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
Push inference runs from a Python notebook to a browser viewer over WebRTC.
|
|
43
|
+
|
|
44
|
+
Bytes flow peer-to-peer over a DataChannel; identical bytes across runs
|
|
45
|
+
and viewers are deduplicated via per-viewer SHA-256 content addressing.
|
|
46
|
+
Auth is GitHub OAuth (`user:email` scope) verified by an HMAC-signed
|
|
47
|
+
JWT — your tokens never leave your machine.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install --pre auvux
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
(`--pre` while we're on the `0.1.0a1` alpha release.)
|
|
56
|
+
|
|
57
|
+
## Use
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
auvux login # GitHub Device Flow, caches a JWT in ~/.auvux/
|
|
61
|
+
auvux whoami # confirm
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
import auvux
|
|
66
|
+
from auvux import lanes, markers
|
|
67
|
+
|
|
68
|
+
# Same as `auvux login`, but inline. Idempotent — no-op if already
|
|
69
|
+
# signed in.
|
|
70
|
+
auvux.login()
|
|
71
|
+
|
|
72
|
+
canvas = auvux.Canvas("Beat Detection")
|
|
73
|
+
canvas.add(
|
|
74
|
+
lanes.Waveform.from_path("demo.wav", size=160, label="mix"),
|
|
75
|
+
markers=[markers.vlines(beats, color="#FFFF00")],
|
|
76
|
+
)
|
|
77
|
+
canvas.add(lanes.Heatmap(spect, yaxis="mel", fmin=30, fmax=11000, size=256))
|
|
78
|
+
canvas.add(lanes.line(t, beat_logits, color="#4CAF50", label="beats", fill=True))
|
|
79
|
+
canvas.minimap(from_lane=0)
|
|
80
|
+
canvas.clicks(beats, freq=800)
|
|
81
|
+
canvas.show() # opens at https://inference.auvux.com
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Open `https://inference.auvux.com` in your browser **before** running
|
|
85
|
+
`canvas.show()` — the publisher waits up to 60 seconds for at least
|
|
86
|
+
one viewer to be present.
|
|
87
|
+
|
|
88
|
+
`Waveform.from_path()` accepts both local paths and `http(s)://` URLs;
|
|
89
|
+
remote files are fetched once into memory and reused without a second
|
|
90
|
+
round-trip on emit.
|
|
91
|
+
|
|
92
|
+
## What gets sent
|
|
93
|
+
|
|
94
|
+
Each call to `canvas.show()` packages:
|
|
95
|
+
|
|
96
|
+
- A `manifest.json` describing the lanes and their layout
|
|
97
|
+
- Sidecar files: WAV / NPY / NPZ / CSV / JSON, per lane type
|
|
98
|
+
- Click-track time lists (if any)
|
|
99
|
+
|
|
100
|
+
The viewer pre-announces which file hashes it already has, so unchanged
|
|
101
|
+
audio across iterations is sent **once** — subsequent runs reuse the
|
|
102
|
+
cached blob.
|
|
103
|
+
|
|
104
|
+
## Auth flow
|
|
105
|
+
|
|
106
|
+
`auvux.login()` runs the GitHub Device Flow with scope `user:email`:
|
|
107
|
+
|
|
108
|
+
1. Asks GitHub for a short user code (`XXXX-XXXX`)
|
|
109
|
+
2. Opens the browser to the verification URL
|
|
110
|
+
3. Polls until you authorise
|
|
111
|
+
4. Swaps the GitHub access token for an auvux JWT at
|
|
112
|
+
`https://inference.auvux.com/api/auth/cli-exchange`
|
|
113
|
+
5. Caches the JWT in `~/.auvux/credentials.json` (chmod 600)
|
|
114
|
+
|
|
115
|
+
Subsequent `auvux.login()` calls are no-ops if the cached token is
|
|
116
|
+
still valid; pass `force=True` to re-authenticate.
|
|
117
|
+
|
|
118
|
+
## Environment
|
|
119
|
+
|
|
120
|
+
| Var | Default | Purpose |
|
|
121
|
+
| --- | --- | --- |
|
|
122
|
+
| `AUVUX_SIGNALLING_URL` | from `~/.auvux/credentials.json`, otherwise `wss://signalling.auvux.com/signalling/me` | Override the signalling endpoint (escape hatch for local dev) |
|
|
123
|
+
| `AUVUX_API_BASE` | `https://inference.auvux.com` | Auvux API host used for `cli-exchange` |
|
|
124
|
+
| `AUVUX_GH_CLIENT_ID` | built-in | GitHub OAuth App client id |
|
|
125
|
+
| `AUVUX_PEER_WAIT` | `60` | Seconds to wait for a viewer before erroring |
|
|
126
|
+
| `AUVUX_ACK_WAIT` | `30` | Seconds to wait per-viewer for delivery ack |
|
|
127
|
+
|
|
128
|
+
## Development
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
git clone https://github.com/auvux/auvux-py
|
|
132
|
+
cd auvux-py
|
|
133
|
+
pip install -e .
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Release a new version: bump `version` in `pyproject.toml`, then
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
git tag v0.1.0a2
|
|
140
|
+
git push --tags
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The `release.yml` workflow builds the wheel + sdist and publishes to
|
|
144
|
+
PyPI via OIDC (Trusted Publisher) — no API token in the repo.
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT — see [LICENSE](LICENSE).
|
auvux-0.1.0a1/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/auvux/auvux-py/main/assets/logo.png" alt="auvux" width="120" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">auvux</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center"><em>Annotate faster. See deeper. Sound smarter.</em></p>
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
Push inference runs from a Python notebook to a browser viewer over WebRTC.
|
|
12
|
+
|
|
13
|
+
Bytes flow peer-to-peer over a DataChannel; identical bytes across runs
|
|
14
|
+
and viewers are deduplicated via per-viewer SHA-256 content addressing.
|
|
15
|
+
Auth is GitHub OAuth (`user:email` scope) verified by an HMAC-signed
|
|
16
|
+
JWT — your tokens never leave your machine.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install --pre auvux
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
(`--pre` while we're on the `0.1.0a1` alpha release.)
|
|
25
|
+
|
|
26
|
+
## Use
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
auvux login # GitHub Device Flow, caches a JWT in ~/.auvux/
|
|
30
|
+
auvux whoami # confirm
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import auvux
|
|
35
|
+
from auvux import lanes, markers
|
|
36
|
+
|
|
37
|
+
# Same as `auvux login`, but inline. Idempotent — no-op if already
|
|
38
|
+
# signed in.
|
|
39
|
+
auvux.login()
|
|
40
|
+
|
|
41
|
+
canvas = auvux.Canvas("Beat Detection")
|
|
42
|
+
canvas.add(
|
|
43
|
+
lanes.Waveform.from_path("demo.wav", size=160, label="mix"),
|
|
44
|
+
markers=[markers.vlines(beats, color="#FFFF00")],
|
|
45
|
+
)
|
|
46
|
+
canvas.add(lanes.Heatmap(spect, yaxis="mel", fmin=30, fmax=11000, size=256))
|
|
47
|
+
canvas.add(lanes.line(t, beat_logits, color="#4CAF50", label="beats", fill=True))
|
|
48
|
+
canvas.minimap(from_lane=0)
|
|
49
|
+
canvas.clicks(beats, freq=800)
|
|
50
|
+
canvas.show() # opens at https://inference.auvux.com
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Open `https://inference.auvux.com` in your browser **before** running
|
|
54
|
+
`canvas.show()` — the publisher waits up to 60 seconds for at least
|
|
55
|
+
one viewer to be present.
|
|
56
|
+
|
|
57
|
+
`Waveform.from_path()` accepts both local paths and `http(s)://` URLs;
|
|
58
|
+
remote files are fetched once into memory and reused without a second
|
|
59
|
+
round-trip on emit.
|
|
60
|
+
|
|
61
|
+
## What gets sent
|
|
62
|
+
|
|
63
|
+
Each call to `canvas.show()` packages:
|
|
64
|
+
|
|
65
|
+
- A `manifest.json` describing the lanes and their layout
|
|
66
|
+
- Sidecar files: WAV / NPY / NPZ / CSV / JSON, per lane type
|
|
67
|
+
- Click-track time lists (if any)
|
|
68
|
+
|
|
69
|
+
The viewer pre-announces which file hashes it already has, so unchanged
|
|
70
|
+
audio across iterations is sent **once** — subsequent runs reuse the
|
|
71
|
+
cached blob.
|
|
72
|
+
|
|
73
|
+
## Auth flow
|
|
74
|
+
|
|
75
|
+
`auvux.login()` runs the GitHub Device Flow with scope `user:email`:
|
|
76
|
+
|
|
77
|
+
1. Asks GitHub for a short user code (`XXXX-XXXX`)
|
|
78
|
+
2. Opens the browser to the verification URL
|
|
79
|
+
3. Polls until you authorise
|
|
80
|
+
4. Swaps the GitHub access token for an auvux JWT at
|
|
81
|
+
`https://inference.auvux.com/api/auth/cli-exchange`
|
|
82
|
+
5. Caches the JWT in `~/.auvux/credentials.json` (chmod 600)
|
|
83
|
+
|
|
84
|
+
Subsequent `auvux.login()` calls are no-ops if the cached token is
|
|
85
|
+
still valid; pass `force=True` to re-authenticate.
|
|
86
|
+
|
|
87
|
+
## Environment
|
|
88
|
+
|
|
89
|
+
| Var | Default | Purpose |
|
|
90
|
+
| --- | --- | --- |
|
|
91
|
+
| `AUVUX_SIGNALLING_URL` | from `~/.auvux/credentials.json`, otherwise `wss://signalling.auvux.com/signalling/me` | Override the signalling endpoint (escape hatch for local dev) |
|
|
92
|
+
| `AUVUX_API_BASE` | `https://inference.auvux.com` | Auvux API host used for `cli-exchange` |
|
|
93
|
+
| `AUVUX_GH_CLIENT_ID` | built-in | GitHub OAuth App client id |
|
|
94
|
+
| `AUVUX_PEER_WAIT` | `60` | Seconds to wait for a viewer before erroring |
|
|
95
|
+
| `AUVUX_ACK_WAIT` | `30` | Seconds to wait per-viewer for delivery ack |
|
|
96
|
+
|
|
97
|
+
## Development
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
git clone https://github.com/auvux/auvux-py
|
|
101
|
+
cd auvux-py
|
|
102
|
+
pip install -e .
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Release a new version: bump `version` in `pyproject.toml`, then
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
git tag v0.1.0a2
|
|
109
|
+
git push --tags
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The `release.yml` workflow builds the wheel + sdist and publishes to
|
|
113
|
+
PyPI via OIDC (Trusted Publisher) — no API token in the repo.
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""auvux — push inference runs from a notebook to the browser viewer
|
|
2
|
+
over WebRTC. Implements the canvas-api-design.md spec.
|
|
3
|
+
|
|
4
|
+
import auvux
|
|
5
|
+
from auvux import lanes, markers
|
|
6
|
+
|
|
7
|
+
auvux.login() # idempotent: skips if already signed in
|
|
8
|
+
|
|
9
|
+
canvas = auvux.Canvas("title") # duration auto-derived
|
|
10
|
+
canvas.add(lanes.Waveform.from_path(audio_path),
|
|
11
|
+
markers=[markers.vlines(beats, color="#FF0")])
|
|
12
|
+
canvas.add(lanes.Heatmap(spect, yaxis="mel", fmin=30, fmax=11000))
|
|
13
|
+
canvas.add(lanes.line(t, y, color="#0AF", label="bpm"))
|
|
14
|
+
canvas.minimap(from_lane=0)
|
|
15
|
+
canvas.clicks(beats, freq=800)
|
|
16
|
+
canvas.show()
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from .canvas import Canvas
|
|
20
|
+
from .auth import login, logout, whoami
|
|
21
|
+
from . import lanes
|
|
22
|
+
from . import markers
|
|
23
|
+
from . import plots
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0a1"
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"Canvas", "login", "logout", "whoami",
|
|
29
|
+
"lanes", "markers", "plots", "__version__",
|
|
30
|
+
]
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""In-process auth — same Device Flow as `auvux login`, callable from
|
|
2
|
+
a notebook cell.
|
|
3
|
+
|
|
4
|
+
Typical use:
|
|
5
|
+
|
|
6
|
+
import auvux
|
|
7
|
+
auvux.login() # prints code, opens browser, polls
|
|
8
|
+
auvux.whoami() # confirm
|
|
9
|
+
canvas.show() # now picks up the cached JWT
|
|
10
|
+
|
|
11
|
+
The CLI's `auvux login` is a thin wrapper around `login()` here, so
|
|
12
|
+
behaviour stays identical across both entry points.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import time
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.parse
|
|
22
|
+
import urllib.request
|
|
23
|
+
import webbrowser
|
|
24
|
+
|
|
25
|
+
from . import credentials
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
DEFAULT_SIGNALLING_URL = os.environ.get(
|
|
29
|
+
"AUVUX_SIGNALLING_URL", "wss://signalling.auvux.com/signalling/me"
|
|
30
|
+
)
|
|
31
|
+
DEFAULT_API_BASE = os.environ.get(
|
|
32
|
+
"AUVUX_API_BASE", "https://inference.auvux.com"
|
|
33
|
+
)
|
|
34
|
+
# Public OAuth App client_id — same one the browser flow uses. Safe to
|
|
35
|
+
# embed; the corresponding secret lives only on Vercel.
|
|
36
|
+
DEFAULT_GH_CLIENT_ID = os.environ.get(
|
|
37
|
+
"AUVUX_GH_CLIENT_ID", "Ov23liqKUtwhWWde5r73"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def login(
|
|
42
|
+
*,
|
|
43
|
+
signalling_url: str = DEFAULT_SIGNALLING_URL,
|
|
44
|
+
api_base: str = DEFAULT_API_BASE,
|
|
45
|
+
client_id: str = DEFAULT_GH_CLIENT_ID,
|
|
46
|
+
open_browser: bool = True,
|
|
47
|
+
force: bool = False,
|
|
48
|
+
) -> credentials.Credentials:
|
|
49
|
+
"""Run the GitHub Device Flow, swap the access_token for an auvux
|
|
50
|
+
JWT at /api/auth/cli-exchange, and cache it in
|
|
51
|
+
~/.auvux/credentials.json. Returns the stored credentials dict.
|
|
52
|
+
|
|
53
|
+
Idempotent by default: if a valid cached JWT already exists, this
|
|
54
|
+
is a no-op (returns the cached credentials) and prints a one-line
|
|
55
|
+
confirmation. Pass `force=True` to throw away the cached token and
|
|
56
|
+
re-run the Device Flow.
|
|
57
|
+
|
|
58
|
+
Safe to call from inside Jupyter — prints the code inline and
|
|
59
|
+
opens the browser to the verification URL.
|
|
60
|
+
"""
|
|
61
|
+
if not force:
|
|
62
|
+
existing = credentials.load()
|
|
63
|
+
if credentials.is_valid(existing) and existing is not None:
|
|
64
|
+
email = existing.get("email") or "<unknown>"
|
|
65
|
+
remaining = max(0, existing.get("expires_at", 0) - int(time.time()))
|
|
66
|
+
days = remaining // 86400
|
|
67
|
+
print(
|
|
68
|
+
f"auvux: already signed in as {email} "
|
|
69
|
+
f"(expires in {days}d). Pass force=True to re-authenticate."
|
|
70
|
+
)
|
|
71
|
+
return existing
|
|
72
|
+
|
|
73
|
+
device = _request_device_code(client_id)
|
|
74
|
+
|
|
75
|
+
print()
|
|
76
|
+
print(" ┌── auvux login ──────────────────────────────────────")
|
|
77
|
+
print(f" │ Go to: {device['verification_uri']}")
|
|
78
|
+
print(f" │ Code: {device['user_code']}")
|
|
79
|
+
print(" └─────────────────────────────────────────────────────")
|
|
80
|
+
print()
|
|
81
|
+
|
|
82
|
+
target = device.get("verification_uri_complete") or device["verification_uri"]
|
|
83
|
+
if open_browser:
|
|
84
|
+
try:
|
|
85
|
+
webbrowser.open(target)
|
|
86
|
+
print(f"auvux: opening {target} in your browser…")
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
gh_token = _poll_for_token(
|
|
91
|
+
client_id,
|
|
92
|
+
device["device_code"],
|
|
93
|
+
interval=int(device.get("interval", 5)),
|
|
94
|
+
expires_in=int(device.get("expires_in", 900)),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
auvux_resp = _exchange(api_base, gh_token)
|
|
98
|
+
|
|
99
|
+
creds: credentials.Credentials = {
|
|
100
|
+
"signalling_url": signalling_url,
|
|
101
|
+
"token": auvux_resp["token"],
|
|
102
|
+
"expires_at": (
|
|
103
|
+
int(time.time()) + int(auvux_resp["expires_in"])
|
|
104
|
+
if auvux_resp.get("expires_in")
|
|
105
|
+
else 0
|
|
106
|
+
),
|
|
107
|
+
"email": auvux_resp.get("email", ""),
|
|
108
|
+
}
|
|
109
|
+
credentials.save(creds)
|
|
110
|
+
print(f"auvux: credentials saved → {credentials.credentials_path()}")
|
|
111
|
+
if creds.get("email"):
|
|
112
|
+
print(f"auvux: signed in as {creds['email']}")
|
|
113
|
+
return creds
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def logout() -> None:
|
|
117
|
+
"""Remove cached credentials. Does nothing if not logged in."""
|
|
118
|
+
credentials.clear()
|
|
119
|
+
print("auvux: credentials removed.")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def whoami() -> credentials.Credentials | None:
|
|
123
|
+
"""Return (and print) the cached identity, or None if logged out."""
|
|
124
|
+
c = credentials.load()
|
|
125
|
+
if not c:
|
|
126
|
+
print("auvux: not logged in (call `auvux.login()`).")
|
|
127
|
+
return None
|
|
128
|
+
print(f"signalling: {c.get('signalling_url', '')}")
|
|
129
|
+
print(f"email: {c.get('email', '') or '<unknown>'}")
|
|
130
|
+
exp = c.get("expires_at", 0)
|
|
131
|
+
if exp:
|
|
132
|
+
remaining = max(0, exp - int(time.time()))
|
|
133
|
+
print(f"expires_in: {remaining}s")
|
|
134
|
+
return c
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ── GitHub Device Flow + cli-exchange (HTTP helpers) ──────────────────
|
|
138
|
+
|
|
139
|
+
def _request_device_code(client_id: str) -> dict:
|
|
140
|
+
"""POST github.com/login/device/code → { device_code, user_code,
|
|
141
|
+
verification_uri, expires_in, interval }."""
|
|
142
|
+
return _post_form(
|
|
143
|
+
"https://github.com/login/device/code",
|
|
144
|
+
{"client_id": client_id, "scope": "user:email"},
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _poll_for_token(
|
|
149
|
+
client_id: str, device_code: str, interval: int, expires_in: int,
|
|
150
|
+
) -> str:
|
|
151
|
+
"""Poll the GitHub token endpoint until we get an access_token, a
|
|
152
|
+
fatal error, or the device code expires. Returns the access_token."""
|
|
153
|
+
deadline = time.time() + expires_in
|
|
154
|
+
sleep_s = max(interval, 1)
|
|
155
|
+
print(f"auvux: waiting for authorisation (poll every {sleep_s}s)…")
|
|
156
|
+
while time.time() < deadline:
|
|
157
|
+
time.sleep(sleep_s)
|
|
158
|
+
try:
|
|
159
|
+
body = _post_form(
|
|
160
|
+
"https://github.com/login/oauth/access_token",
|
|
161
|
+
{
|
|
162
|
+
"client_id": client_id,
|
|
163
|
+
"device_code": device_code,
|
|
164
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
except urllib.error.HTTPError as e:
|
|
168
|
+
raise RuntimeError(f"GitHub token poll HTTP {e.code}") from e
|
|
169
|
+
if "access_token" in body:
|
|
170
|
+
return body["access_token"]
|
|
171
|
+
err = body.get("error")
|
|
172
|
+
if err == "authorization_pending":
|
|
173
|
+
continue
|
|
174
|
+
if err == "slow_down":
|
|
175
|
+
sleep_s += 5
|
|
176
|
+
continue
|
|
177
|
+
if err in {"expired_token", "access_denied"}:
|
|
178
|
+
raise RuntimeError(f"GitHub login failed: {err}")
|
|
179
|
+
raise RuntimeError(f"unexpected token response: {body}")
|
|
180
|
+
raise RuntimeError("login timed out — device code expired before authorisation")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _exchange(api_base: str, gh_token: str) -> dict:
|
|
184
|
+
"""Swap a GitHub access_token for an auvux JWT.
|
|
185
|
+
|
|
186
|
+
Cloudflare bot protection blocks Python's default
|
|
187
|
+
`User-Agent: Python-urllib/…`, so we send a recognisable UA string.
|
|
188
|
+
"""
|
|
189
|
+
req = urllib.request.Request(
|
|
190
|
+
api_base.rstrip("/") + "/api/auth/cli-exchange",
|
|
191
|
+
data=json.dumps({"github_token": gh_token}).encode(),
|
|
192
|
+
headers={
|
|
193
|
+
"Content-Type": "application/json",
|
|
194
|
+
"Accept": "application/json",
|
|
195
|
+
"User-Agent": "auvux-cli/0.1 (+https://inference.auvux.com)",
|
|
196
|
+
},
|
|
197
|
+
method="POST",
|
|
198
|
+
)
|
|
199
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
200
|
+
return json.loads(resp.read())
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _post_form(url: str, fields: dict) -> dict:
|
|
204
|
+
data = urllib.parse.urlencode(fields).encode()
|
|
205
|
+
req = urllib.request.Request(
|
|
206
|
+
url,
|
|
207
|
+
data=data,
|
|
208
|
+
headers={
|
|
209
|
+
"Accept": "application/json",
|
|
210
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
211
|
+
"User-Agent": "auvux-cli",
|
|
212
|
+
},
|
|
213
|
+
method="POST",
|
|
214
|
+
)
|
|
215
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
216
|
+
return json.loads(resp.read())
|