ml-dash 0.5.9__tar.gz → 0.6.3__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.
- {ml_dash-0.5.9 → ml_dash-0.6.3}/PKG-INFO +49 -20
- {ml_dash-0.5.9 → ml_dash-0.6.3}/README.md +43 -19
- {ml_dash-0.5.9 → ml_dash-0.6.3}/pyproject.toml +7 -1
- ml_dash-0.6.3/src/ml_dash/__init__.py +59 -0
- ml_dash-0.6.3/src/ml_dash/auth/__init__.py +51 -0
- ml_dash-0.6.3/src/ml_dash/auth/constants.py +10 -0
- ml_dash-0.6.3/src/ml_dash/auth/device_flow.py +237 -0
- ml_dash-0.6.3/src/ml_dash/auth/device_secret.py +49 -0
- ml_dash-0.6.3/src/ml_dash/auth/exceptions.py +31 -0
- ml_dash-0.6.3/src/ml_dash/auth/token_storage.py +303 -0
- ml_dash-0.6.3/src/ml_dash/auto_start.py +65 -0
- {ml_dash-0.5.9 → ml_dash-0.6.3}/src/ml_dash/cli.py +29 -3
- ml_dash-0.6.3/src/ml_dash/cli_commands/api.py +165 -0
- ml_dash-0.6.3/src/ml_dash/cli_commands/download.py +859 -0
- {ml_dash-0.5.9 → ml_dash-0.6.3}/src/ml_dash/cli_commands/list.py +156 -47
- ml_dash-0.6.3/src/ml_dash/cli_commands/login.py +232 -0
- ml_dash-0.6.3/src/ml_dash/cli_commands/logout.py +54 -0
- ml_dash-0.6.3/src/ml_dash/cli_commands/profile.py +92 -0
- ml_dash-0.6.3/src/ml_dash/cli_commands/upload.py +1398 -0
- {ml_dash-0.5.9 → ml_dash-0.6.3}/src/ml_dash/client.py +170 -49
- ml_dash-0.6.3/src/ml_dash/config.py +133 -0
- ml_dash-0.6.3/src/ml_dash/experiment.py +1363 -0
- ml_dash-0.6.3/src/ml_dash/files.py +1496 -0
- {ml_dash-0.5.9 → ml_dash-0.6.3}/src/ml_dash/log.py +7 -7
- ml_dash-0.6.3/src/ml_dash/metric.py +740 -0
- {ml_dash-0.5.9 → ml_dash-0.6.3}/src/ml_dash/params.py +98 -9
- ml_dash-0.6.3/src/ml_dash/remote_auto_start.py +58 -0
- ml_dash-0.6.3/src/ml_dash/run.py +231 -0
- ml_dash-0.6.3/src/ml_dash/snowflake.py +173 -0
- ml_dash-0.6.3/src/ml_dash/storage.py +1099 -0
- ml_dash-0.5.9/src/ml_dash/__init__.py +0 -59
- ml_dash-0.5.9/src/ml_dash/auto_start.py +0 -42
- ml_dash-0.5.9/src/ml_dash/cli_commands/download.py +0 -797
- ml_dash-0.5.9/src/ml_dash/cli_commands/upload.py +0 -1298
- ml_dash-0.5.9/src/ml_dash/config.py +0 -119
- ml_dash-0.5.9/src/ml_dash/experiment.py +0 -1020
- ml_dash-0.5.9/src/ml_dash/files.py +0 -688
- ml_dash-0.5.9/src/ml_dash/metric.py +0 -292
- ml_dash-0.5.9/src/ml_dash/storage.py +0 -1115
- {ml_dash-0.5.9 → ml_dash-0.6.3}/LICENSE +0 -0
- {ml_dash-0.5.9 → ml_dash-0.6.3}/src/ml_dash/cli_commands/__init__.py +0 -0
- {ml_dash-0.5.9 → ml_dash-0.6.3}/src/ml_dash/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: ml-dash
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.3
|
|
4
4
|
Summary: ML experiment tracking and data storage
|
|
5
5
|
Keywords: machine-learning,experiment-tracking,mlops,data-storage
|
|
6
6
|
Author: Ge Yang, Tom Tao
|
|
@@ -42,6 +42,10 @@ Requires-Dist: imageio>=2.31.0
|
|
|
42
42
|
Requires-Dist: imageio-ffmpeg>=0.4.9
|
|
43
43
|
Requires-Dist: scikit-image>=0.21.0
|
|
44
44
|
Requires-Dist: rich>=13.0.0
|
|
45
|
+
Requires-Dist: cryptography>=42.0.0
|
|
46
|
+
Requires-Dist: params-proto>=3.0.0
|
|
47
|
+
Requires-Dist: keyring>=25.0.0 ; extra == 'auth'
|
|
48
|
+
Requires-Dist: qrcode>=8.0.0 ; extra == 'auth'
|
|
45
49
|
Requires-Dist: pytest>=8.0.0 ; extra == 'dev'
|
|
46
50
|
Requires-Dist: pytest-asyncio>=0.23.0 ; extra == 'dev'
|
|
47
51
|
Requires-Dist: sphinx>=7.2.0 ; extra == 'dev'
|
|
@@ -58,17 +62,19 @@ Requires-Dist: linkify-it-py>=2.0.0 ; extra == 'dev'
|
|
|
58
62
|
Requires-Dist: ruff>=0.3.0 ; extra == 'dev'
|
|
59
63
|
Requires-Dist: mypy>=1.9.0 ; extra == 'dev'
|
|
60
64
|
Requires-Python: >=3.9
|
|
65
|
+
Provides-Extra: auth
|
|
61
66
|
Provides-Extra: dev
|
|
62
67
|
Description-Content-Type: text/markdown
|
|
63
68
|
|
|
64
69
|
# ML-Dash
|
|
65
70
|
|
|
66
|
-
A simple and flexible SDK for ML experiment
|
|
71
|
+
A simple and flexible SDK for ML experiment tracking and data storage.
|
|
67
72
|
|
|
68
73
|
## Features
|
|
69
74
|
|
|
70
|
-
- **Three Usage Styles**:
|
|
75
|
+
- **Three Usage Styles**: Pre-configured singleton (dxp), context manager, or direct instantiation
|
|
71
76
|
- **Dual Operation Modes**: Remote (API server) or local (filesystem)
|
|
77
|
+
- **OAuth2 Authentication**: Secure device flow authentication for CLI and SDK
|
|
72
78
|
- **Auto-creation**: Automatically creates namespace, project, and folder hierarchy
|
|
73
79
|
- **Upsert Behavior**: Updates existing experiments or creates new ones
|
|
74
80
|
- **Experiment Lifecycle**: Automatic status tracking (RUNNING, COMPLETED, FAILED, CANCELLED)
|
|
@@ -87,50 +93,73 @@ A simple and flexible SDK for ML experiment metricing and data storage.
|
|
|
87
93
|
<td>
|
|
88
94
|
|
|
89
95
|
```bash
|
|
90
|
-
uv add ml-dash
|
|
96
|
+
uv add ml-dash==0.6.2rc1
|
|
91
97
|
```
|
|
92
98
|
|
|
93
99
|
</td>
|
|
94
100
|
<td>
|
|
95
101
|
|
|
96
102
|
```bash
|
|
97
|
-
pip install ml-dash
|
|
103
|
+
pip install ml-dash==0.6.2rc1
|
|
98
104
|
```
|
|
99
105
|
|
|
100
106
|
</td>
|
|
101
107
|
</tr>
|
|
102
108
|
</table>
|
|
103
109
|
|
|
104
|
-
##
|
|
110
|
+
## Quick Start
|
|
105
111
|
|
|
106
|
-
###
|
|
112
|
+
### 1. Authenticate (Required for Remote Mode)
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
ml-dash login
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
This opens your browser for secure OAuth2 authentication. Your credentials are stored securely in your system keychain.
|
|
119
|
+
|
|
120
|
+
### 2. Start Tracking Experiments
|
|
121
|
+
|
|
122
|
+
#### Option A: Use the Pre-configured Singleton (Easiest)
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from ml_dash.auto_start import dxp
|
|
126
|
+
|
|
127
|
+
# Start experiment (uploads to https://api.dash.ml by default)
|
|
128
|
+
with dxp.run:
|
|
129
|
+
dxp.log("Training started", level="info")
|
|
130
|
+
dxp.params.set(learning_rate=0.001, batch_size=32)
|
|
131
|
+
|
|
132
|
+
for epoch in range(10):
|
|
133
|
+
loss = train_one_epoch()
|
|
134
|
+
dxp.metrics("train").log(loss=loss, epoch=epoch)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
#### Option B: Create Your Own Experiment
|
|
107
138
|
|
|
108
139
|
```python
|
|
109
140
|
from ml_dash import Experiment
|
|
110
141
|
|
|
111
142
|
with Experiment(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
)
|
|
117
|
-
print(f"Experiment ID: {experiment.id}")
|
|
143
|
+
prefix="alice/my-project/my-experiment",
|
|
144
|
+
dash_url="https://api.dash.ml", # token auto-loaded
|
|
145
|
+
).run as experiment:
|
|
146
|
+
experiment.log("Hello!", level="info")
|
|
147
|
+
experiment.params.set(lr=0.001)
|
|
118
148
|
```
|
|
119
149
|
|
|
120
|
-
|
|
150
|
+
#### Option C: Local Mode (No Authentication Required)
|
|
121
151
|
|
|
122
152
|
```python
|
|
123
153
|
from ml_dash import Experiment
|
|
124
154
|
|
|
125
155
|
with Experiment(
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
pass # Your code here
|
|
156
|
+
project="my-project", prefix="my-experiment", dash_root=".dash"
|
|
157
|
+
).run as experiment:
|
|
158
|
+
experiment.log("Running locally", level="info")
|
|
159
|
+
|
|
131
160
|
```
|
|
132
161
|
|
|
133
|
-
See [
|
|
162
|
+
See [docs/getting-started.md](docs/getting-started.md) for more examples.
|
|
134
163
|
|
|
135
164
|
## Development Setup
|
|
136
165
|
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# ML-Dash
|
|
2
2
|
|
|
3
|
-
A simple and flexible SDK for ML experiment
|
|
3
|
+
A simple and flexible SDK for ML experiment tracking and data storage.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- **Three Usage Styles**:
|
|
7
|
+
- **Three Usage Styles**: Pre-configured singleton (dxp), context manager, or direct instantiation
|
|
8
8
|
- **Dual Operation Modes**: Remote (API server) or local (filesystem)
|
|
9
|
+
- **OAuth2 Authentication**: Secure device flow authentication for CLI and SDK
|
|
9
10
|
- **Auto-creation**: Automatically creates namespace, project, and folder hierarchy
|
|
10
11
|
- **Upsert Behavior**: Updates existing experiments or creates new ones
|
|
11
12
|
- **Experiment Lifecycle**: Automatic status tracking (RUNNING, COMPLETED, FAILED, CANCELLED)
|
|
@@ -24,50 +25,73 @@ A simple and flexible SDK for ML experiment metricing and data storage.
|
|
|
24
25
|
<td>
|
|
25
26
|
|
|
26
27
|
```bash
|
|
27
|
-
uv add ml-dash
|
|
28
|
+
uv add ml-dash==0.6.2rc1
|
|
28
29
|
```
|
|
29
30
|
|
|
30
31
|
</td>
|
|
31
32
|
<td>
|
|
32
33
|
|
|
33
34
|
```bash
|
|
34
|
-
pip install ml-dash
|
|
35
|
+
pip install ml-dash==0.6.2rc1
|
|
35
36
|
```
|
|
36
37
|
|
|
37
38
|
</td>
|
|
38
39
|
</tr>
|
|
39
40
|
</table>
|
|
40
41
|
|
|
41
|
-
##
|
|
42
|
+
## Quick Start
|
|
42
43
|
|
|
43
|
-
###
|
|
44
|
+
### 1. Authenticate (Required for Remote Mode)
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
ml-dash login
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
This opens your browser for secure OAuth2 authentication. Your credentials are stored securely in your system keychain.
|
|
51
|
+
|
|
52
|
+
### 2. Start Tracking Experiments
|
|
53
|
+
|
|
54
|
+
#### Option A: Use the Pre-configured Singleton (Easiest)
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from ml_dash.auto_start import dxp
|
|
58
|
+
|
|
59
|
+
# Start experiment (uploads to https://api.dash.ml by default)
|
|
60
|
+
with dxp.run:
|
|
61
|
+
dxp.log("Training started", level="info")
|
|
62
|
+
dxp.params.set(learning_rate=0.001, batch_size=32)
|
|
63
|
+
|
|
64
|
+
for epoch in range(10):
|
|
65
|
+
loss = train_one_epoch()
|
|
66
|
+
dxp.metrics("train").log(loss=loss, epoch=epoch)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
#### Option B: Create Your Own Experiment
|
|
44
70
|
|
|
45
71
|
```python
|
|
46
72
|
from ml_dash import Experiment
|
|
47
73
|
|
|
48
74
|
with Experiment(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
)
|
|
54
|
-
print(f"Experiment ID: {experiment.id}")
|
|
75
|
+
prefix="alice/my-project/my-experiment",
|
|
76
|
+
dash_url="https://api.dash.ml", # token auto-loaded
|
|
77
|
+
).run as experiment:
|
|
78
|
+
experiment.log("Hello!", level="info")
|
|
79
|
+
experiment.params.set(lr=0.001)
|
|
55
80
|
```
|
|
56
81
|
|
|
57
|
-
|
|
82
|
+
#### Option C: Local Mode (No Authentication Required)
|
|
58
83
|
|
|
59
84
|
```python
|
|
60
85
|
from ml_dash import Experiment
|
|
61
86
|
|
|
62
87
|
with Experiment(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
pass # Your code here
|
|
88
|
+
project="my-project", prefix="my-experiment", dash_root=".dash"
|
|
89
|
+
).run as experiment:
|
|
90
|
+
experiment.log("Running locally", level="info")
|
|
91
|
+
|
|
68
92
|
```
|
|
69
93
|
|
|
70
|
-
See [
|
|
94
|
+
See [docs/getting-started.md](docs/getting-started.md) for more examples.
|
|
71
95
|
|
|
72
96
|
## Development Setup
|
|
73
97
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "ml-dash"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.6.3"
|
|
4
4
|
description = "ML experiment tracking and data storage"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.9"
|
|
@@ -30,12 +30,18 @@ dependencies = [
|
|
|
30
30
|
"imageio-ffmpeg>=0.4.9",
|
|
31
31
|
"scikit-image>=0.21.0",
|
|
32
32
|
"rich>=13.0.0",
|
|
33
|
+
"cryptography>=42.0.0",
|
|
34
|
+
"params-proto>=3.0.0",
|
|
33
35
|
]
|
|
34
36
|
|
|
35
37
|
[project.scripts]
|
|
36
38
|
ml-dash = "ml_dash.cli:main"
|
|
37
39
|
|
|
38
40
|
[project.optional-dependencies]
|
|
41
|
+
auth = [
|
|
42
|
+
"keyring>=25.0.0",
|
|
43
|
+
"qrcode>=8.0.0",
|
|
44
|
+
]
|
|
39
45
|
dev = [
|
|
40
46
|
"pytest>=8.0.0",
|
|
41
47
|
"pytest-asyncio>=0.23.0",
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ML-Dash Python SDK
|
|
3
|
+
|
|
4
|
+
A simple and flexible SDK for ML experiment metricing and data storage.
|
|
5
|
+
|
|
6
|
+
Prefix format: {owner}/{project}/path.../[name]
|
|
7
|
+
- owner: First segment (e.g., your username)
|
|
8
|
+
- project: Second segment (e.g., project name)
|
|
9
|
+
- path: Remaining segments form the folder structure
|
|
10
|
+
- name: Derived from last segment (may be a seed/id)
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
|
|
14
|
+
from ml_dash import Experiment
|
|
15
|
+
|
|
16
|
+
# Local mode - explicit configuration
|
|
17
|
+
with Experiment(
|
|
18
|
+
prefix="ge/my-project/experiments/exp1",
|
|
19
|
+
dash_root=".dash"
|
|
20
|
+
).run as exp:
|
|
21
|
+
exp.log("Training started")
|
|
22
|
+
exp.params.set(lr=0.001)
|
|
23
|
+
exp.metrics("train").log(loss=0.5, step=0)
|
|
24
|
+
|
|
25
|
+
# Default: Remote mode (defaults to https://api.dash.ml)
|
|
26
|
+
with Experiment(prefix="ge/my-project/experiments/exp1").run as exp:
|
|
27
|
+
exp.log("Training started")
|
|
28
|
+
exp.params.set(lr=0.001)
|
|
29
|
+
exp.metrics("train").log(loss=0.5, step=0)
|
|
30
|
+
|
|
31
|
+
# Decorator style
|
|
32
|
+
from ml_dash import ml_dash_experiment
|
|
33
|
+
|
|
34
|
+
@ml_dash_experiment(prefix="ge/my-project/experiments/exp1")
|
|
35
|
+
def train_model(exp):
|
|
36
|
+
exp.log("Training started")
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from .client import RemoteClient
|
|
40
|
+
from .experiment import Experiment, OperationMode, RunManager, ml_dash_experiment
|
|
41
|
+
from .log import LogBuilder, LogLevel
|
|
42
|
+
from .params import ParametersBuilder
|
|
43
|
+
from .run import RUN
|
|
44
|
+
from .storage import LocalStorage
|
|
45
|
+
|
|
46
|
+
__version__ = "0.6.3"
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"Experiment",
|
|
50
|
+
"ml_dash_experiment",
|
|
51
|
+
"OperationMode",
|
|
52
|
+
"RunManager",
|
|
53
|
+
"RemoteClient",
|
|
54
|
+
"LocalStorage",
|
|
55
|
+
"LogLevel",
|
|
56
|
+
"LogBuilder",
|
|
57
|
+
"ParametersBuilder",
|
|
58
|
+
"RUN",
|
|
59
|
+
]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Authentication module for ml-dash."""
|
|
2
|
+
|
|
3
|
+
from .constants import VUER_AUTH_URL, CLIENT_ID, DEFAULT_SCOPE
|
|
4
|
+
from .device_flow import DeviceFlowClient, DeviceFlowResponse
|
|
5
|
+
from .device_secret import (
|
|
6
|
+
generate_device_secret,
|
|
7
|
+
hash_device_secret,
|
|
8
|
+
get_or_create_device_secret,
|
|
9
|
+
)
|
|
10
|
+
from .token_storage import (
|
|
11
|
+
TokenStorage,
|
|
12
|
+
KeyringStorage,
|
|
13
|
+
EncryptedFileStorage,
|
|
14
|
+
PlaintextFileStorage,
|
|
15
|
+
get_token_storage,
|
|
16
|
+
)
|
|
17
|
+
from .exceptions import (
|
|
18
|
+
AuthenticationError,
|
|
19
|
+
NotAuthenticatedError,
|
|
20
|
+
DeviceCodeExpiredError,
|
|
21
|
+
AuthorizationDeniedError,
|
|
22
|
+
TokenExchangeError,
|
|
23
|
+
StorageError,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
# Constants
|
|
28
|
+
"VUER_AUTH_URL",
|
|
29
|
+
"CLIENT_ID",
|
|
30
|
+
"DEFAULT_SCOPE",
|
|
31
|
+
# Device Flow
|
|
32
|
+
"DeviceFlowClient",
|
|
33
|
+
"DeviceFlowResponse",
|
|
34
|
+
# Device Secret
|
|
35
|
+
"generate_device_secret",
|
|
36
|
+
"hash_device_secret",
|
|
37
|
+
"get_or_create_device_secret",
|
|
38
|
+
# Token Storage
|
|
39
|
+
"TokenStorage",
|
|
40
|
+
"KeyringStorage",
|
|
41
|
+
"EncryptedFileStorage",
|
|
42
|
+
"PlaintextFileStorage",
|
|
43
|
+
"get_token_storage",
|
|
44
|
+
# Exceptions
|
|
45
|
+
"AuthenticationError",
|
|
46
|
+
"NotAuthenticatedError",
|
|
47
|
+
"DeviceCodeExpiredError",
|
|
48
|
+
"AuthorizationDeniedError",
|
|
49
|
+
"TokenExchangeError",
|
|
50
|
+
"StorageError",
|
|
51
|
+
]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Authentication constants for ml-dash."""
|
|
2
|
+
|
|
3
|
+
# Vuer-auth server URL
|
|
4
|
+
VUER_AUTH_URL = "https://auth.vuer.ai"
|
|
5
|
+
|
|
6
|
+
# OAuth client ID for ml-dash
|
|
7
|
+
CLIENT_ID = "ml-dash-client"
|
|
8
|
+
|
|
9
|
+
# Default OAuth scopes (no offline_access since we get permanent tokens)
|
|
10
|
+
DEFAULT_SCOPE = "openid profile email"
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Device authorization flow (RFC 8628) client for ml-dash."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .constants import VUER_AUTH_URL, CLIENT_ID, DEFAULT_SCOPE
|
|
10
|
+
from .device_secret import hash_device_secret
|
|
11
|
+
from .exceptions import (
|
|
12
|
+
DeviceCodeExpiredError,
|
|
13
|
+
AuthorizationDeniedError,
|
|
14
|
+
TokenExchangeError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class DeviceFlowResponse:
|
|
20
|
+
"""Response from device flow initiation."""
|
|
21
|
+
|
|
22
|
+
user_code: str
|
|
23
|
+
device_code: str
|
|
24
|
+
verification_uri: str
|
|
25
|
+
verification_uri_complete: str
|
|
26
|
+
expires_in: int
|
|
27
|
+
interval: int
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DeviceFlowClient:
|
|
31
|
+
"""Client for OAuth 2.0 Device Authorization Flow (RFC 8628)."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, device_secret: str, ml_dash_server_url: str):
|
|
34
|
+
"""Initialize device flow client.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
device_secret: Persistent device secret for this client
|
|
38
|
+
ml_dash_server_url: ML-Dash server URL for token exchange
|
|
39
|
+
"""
|
|
40
|
+
self.device_secret = device_secret
|
|
41
|
+
self.ml_dash_server_url = ml_dash_server_url.rstrip("/")
|
|
42
|
+
|
|
43
|
+
def start_device_flow(self, scope: str = DEFAULT_SCOPE) -> DeviceFlowResponse:
|
|
44
|
+
"""Initiate device authorization flow with vuer-auth.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
scope: OAuth scopes to request
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
DeviceFlowResponse with user code and verification URI
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
httpx.HTTPError: If request fails
|
|
54
|
+
"""
|
|
55
|
+
response = httpx.post(
|
|
56
|
+
f"{VUER_AUTH_URL}/api/device-flow/start",
|
|
57
|
+
json={
|
|
58
|
+
"client_id": CLIENT_ID,
|
|
59
|
+
"scope": scope,
|
|
60
|
+
"device_secret_hash": hash_device_secret(self.device_secret),
|
|
61
|
+
},
|
|
62
|
+
timeout=10.0,
|
|
63
|
+
)
|
|
64
|
+
response.raise_for_status()
|
|
65
|
+
data = response.json()
|
|
66
|
+
|
|
67
|
+
return DeviceFlowResponse(
|
|
68
|
+
user_code=data["user_code"],
|
|
69
|
+
device_code=data.get("device_code", ""),
|
|
70
|
+
verification_uri=data["verification_uri"],
|
|
71
|
+
verification_uri_complete=data.get(
|
|
72
|
+
"verification_uri_complete",
|
|
73
|
+
f"{data['verification_uri']}?code={data['user_code'].replace('-', '')}"
|
|
74
|
+
),
|
|
75
|
+
expires_in=data.get("expires_in", 600),
|
|
76
|
+
interval=data.get("interval", 5),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def poll_for_token(
|
|
80
|
+
self,
|
|
81
|
+
max_attempts: int = 120,
|
|
82
|
+
progress_callback: Optional[callable] = None,
|
|
83
|
+
) -> str:
|
|
84
|
+
"""Poll vuer-auth for authorization completion.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
max_attempts: Maximum polling attempts (default: 120 = 10 minutes at 5s intervals)
|
|
88
|
+
progress_callback: Optional callback(elapsed_seconds) for progress updates
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Vuer-auth access token (JWT)
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
DeviceCodeExpiredError: If device code expires
|
|
95
|
+
AuthorizationDeniedError: If user denies authorization
|
|
96
|
+
TimeoutError: If polling times out
|
|
97
|
+
"""
|
|
98
|
+
device_secret_hash = hash_device_secret(self.device_secret)
|
|
99
|
+
|
|
100
|
+
for attempt in range(max_attempts):
|
|
101
|
+
elapsed = attempt * 5 # 5 second intervals
|
|
102
|
+
|
|
103
|
+
if progress_callback:
|
|
104
|
+
progress_callback(elapsed)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
response = httpx.post(
|
|
108
|
+
f"{VUER_AUTH_URL}/api/device-flow/poll",
|
|
109
|
+
json={
|
|
110
|
+
"client_id": CLIENT_ID,
|
|
111
|
+
"device_secret_hash": device_secret_hash,
|
|
112
|
+
},
|
|
113
|
+
timeout=10.0,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if response.status_code == 200:
|
|
117
|
+
# Authorization successful
|
|
118
|
+
data = response.json()
|
|
119
|
+
return data["access_token"]
|
|
120
|
+
|
|
121
|
+
# Check error responses
|
|
122
|
+
error_data = response.json()
|
|
123
|
+
error = error_data.get("error")
|
|
124
|
+
|
|
125
|
+
if error == "authorization_pending":
|
|
126
|
+
# Still waiting for user authorization
|
|
127
|
+
time.sleep(5)
|
|
128
|
+
continue
|
|
129
|
+
elif error == "expired_token":
|
|
130
|
+
raise DeviceCodeExpiredError(
|
|
131
|
+
"Device code expired. Please run 'ml-dash login' again."
|
|
132
|
+
)
|
|
133
|
+
elif error == "access_denied":
|
|
134
|
+
raise AuthorizationDeniedError(
|
|
135
|
+
"User denied authorization request."
|
|
136
|
+
)
|
|
137
|
+
elif error == "slow_down":
|
|
138
|
+
# Server requests slower polling
|
|
139
|
+
time.sleep(10)
|
|
140
|
+
continue
|
|
141
|
+
else:
|
|
142
|
+
# Unknown error
|
|
143
|
+
raise TokenExchangeError(f"Device flow error: {error}")
|
|
144
|
+
|
|
145
|
+
except httpx.HTTPError as e:
|
|
146
|
+
# Network error, retry
|
|
147
|
+
time.sleep(5)
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
raise TimeoutError(
|
|
151
|
+
"Authorization timed out after 10 minutes. Please run 'ml-dash login' again."
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def exchange_token(self, vuer_auth_token: str) -> str:
|
|
155
|
+
"""Exchange vuer-auth token for ml-dash permanent token.
|
|
156
|
+
|
|
157
|
+
This calls the ml-dash server's token exchange endpoint.
|
|
158
|
+
The server will:
|
|
159
|
+
1. Decode the vuer-auth JWT
|
|
160
|
+
2. Validate signature and expiry
|
|
161
|
+
3. Extract username from claims
|
|
162
|
+
4. Generate a permanent ml-dash token for that username
|
|
163
|
+
5. Return the ml-dash token
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
vuer_auth_token: Temporary vuer-auth JWT access token
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Permanent ml-dash token string
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
TokenExchangeError: If exchange fails
|
|
173
|
+
httpx.HTTPError: If request fails
|
|
174
|
+
"""
|
|
175
|
+
try:
|
|
176
|
+
response = httpx.post(
|
|
177
|
+
f"{self.ml_dash_server_url}/api/auth/exchange",
|
|
178
|
+
headers={"Authorization": f"Bearer {vuer_auth_token}"},
|
|
179
|
+
timeout=10.0,
|
|
180
|
+
)
|
|
181
|
+
response.raise_for_status()
|
|
182
|
+
data = response.json()
|
|
183
|
+
|
|
184
|
+
ml_dash_token = data.get("ml_dash_token")
|
|
185
|
+
if not ml_dash_token:
|
|
186
|
+
raise TokenExchangeError(
|
|
187
|
+
"Server response missing ml_dash_token field"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
return ml_dash_token
|
|
191
|
+
|
|
192
|
+
except httpx.HTTPStatusError as e:
|
|
193
|
+
if e.response.status_code == 401:
|
|
194
|
+
raise TokenExchangeError(
|
|
195
|
+
"Vuer-auth token invalid or expired. Please try logging in again."
|
|
196
|
+
)
|
|
197
|
+
elif e.response.status_code == 404:
|
|
198
|
+
raise TokenExchangeError(
|
|
199
|
+
"Token exchange endpoint not found. "
|
|
200
|
+
"Please ensure ml-dash server is up to date."
|
|
201
|
+
)
|
|
202
|
+
else:
|
|
203
|
+
raise TokenExchangeError(
|
|
204
|
+
f"Token exchange failed: {e.response.status_code} {e.response.text}"
|
|
205
|
+
)
|
|
206
|
+
except httpx.HTTPError as e:
|
|
207
|
+
raise TokenExchangeError(f"Network error during token exchange: {e}")
|
|
208
|
+
|
|
209
|
+
def authenticate(
|
|
210
|
+
self, progress_callback: Optional[callable] = None
|
|
211
|
+
) -> str:
|
|
212
|
+
"""Complete full device authorization flow.
|
|
213
|
+
|
|
214
|
+
This is a convenience method that:
|
|
215
|
+
1. Starts device flow with vuer-auth
|
|
216
|
+
2. Polls for authorization
|
|
217
|
+
3. Exchanges vuer-auth token for ml-dash token
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
progress_callback: Optional callback(elapsed_seconds) for progress updates
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
Permanent ml-dash token string
|
|
224
|
+
|
|
225
|
+
Raises:
|
|
226
|
+
Various authentication exceptions
|
|
227
|
+
"""
|
|
228
|
+
# Step 1: Start device flow
|
|
229
|
+
flow = self.start_device_flow()
|
|
230
|
+
|
|
231
|
+
# Step 2: Poll for authorization (caller should display user_code)
|
|
232
|
+
vuer_auth_token = self.poll_for_token(progress_callback=progress_callback)
|
|
233
|
+
|
|
234
|
+
# Step 3: Exchange for ml-dash token
|
|
235
|
+
ml_dash_token = self.exchange_token(vuer_auth_token)
|
|
236
|
+
|
|
237
|
+
return ml_dash_token
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Device secret generation and management for ml-dash."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import secrets
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from ml_dash.config import Config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def generate_device_secret() -> str:
|
|
12
|
+
"""Generate a cryptographically secure device secret.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
A 64-character hexadecimal string (256 bits of entropy)
|
|
16
|
+
"""
|
|
17
|
+
return secrets.token_hex(32) # 32 bytes = 256 bits
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def hash_device_secret(secret: str) -> str:
|
|
21
|
+
"""Hash device secret using SHA256.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
secret: The device secret to hash
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
SHA256 hash as hexadecimal string
|
|
28
|
+
"""
|
|
29
|
+
return hashlib.sha256(secret.encode()).hexdigest()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_or_create_device_secret(config: "Config") -> str:
|
|
33
|
+
"""Load device secret from config or generate a new one.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
config: ML-Dash configuration object
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Device secret string
|
|
40
|
+
"""
|
|
41
|
+
device_secret = config.device_secret
|
|
42
|
+
|
|
43
|
+
if not device_secret:
|
|
44
|
+
# Generate new device secret
|
|
45
|
+
device_secret = generate_device_secret()
|
|
46
|
+
config.set("device_secret", device_secret)
|
|
47
|
+
config.save()
|
|
48
|
+
|
|
49
|
+
return device_secret
|