tinybird 0.0.1.dev246__py3-none-any.whl → 0.0.1.dev248__py3-none-any.whl
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.
Potentially problematic release.
This version of tinybird might be problematic. Click here for more details.
- tinybird/ch_utils/constants.py +2 -0
- tinybird/prompts.py +2 -0
- tinybird/tb/__cli__.py +2 -2
- tinybird/tb/modules/agent/agent.py +107 -18
- tinybird/tb/modules/agent/models.py +6 -0
- tinybird/tb/modules/agent/prompts.py +57 -29
- tinybird/tb/modules/agent/tools/append.py +55 -0
- tinybird/tb/modules/agent/tools/build.py +1 -0
- tinybird/tb/modules/agent/tools/create_datafile.py +8 -3
- tinybird/tb/modules/agent/tools/deploy.py +1 -1
- tinybird/tb/modules/agent/tools/mock.py +59 -0
- tinybird/tb/modules/agent/tools/plan.py +1 -1
- tinybird/tb/modules/agent/tools/read_fixture_data.py +28 -0
- tinybird/tb/modules/agent/utils.py +296 -3
- tinybird/tb/modules/build.py +4 -1
- tinybird/tb/modules/build_common.py +2 -3
- tinybird/tb/modules/cli.py +9 -1
- tinybird/tb/modules/create.py +1 -1
- tinybird/tb/modules/feedback_manager.py +1 -0
- tinybird/tb/modules/llm.py +1 -1
- tinybird/tb/modules/login.py +6 -301
- tinybird/tb/modules/login_common.py +310 -0
- tinybird/tb/modules/mock.py +3 -69
- tinybird/tb/modules/mock_common.py +71 -0
- tinybird/tb/modules/project.py +9 -0
- {tinybird-0.0.1.dev246.dist-info → tinybird-0.0.1.dev248.dist-info}/METADATA +1 -1
- {tinybird-0.0.1.dev246.dist-info → tinybird-0.0.1.dev248.dist-info}/RECORD +30 -25
- {tinybird-0.0.1.dev246.dist-info → tinybird-0.0.1.dev248.dist-info}/WHEEL +0 -0
- {tinybird-0.0.1.dev246.dist-info → tinybird-0.0.1.dev248.dist-info}/entry_points.txt +0 -0
- {tinybird-0.0.1.dev246.dist-info → tinybird-0.0.1.dev248.dist-info}/top_level.txt +0 -0
tinybird/tb/modules/login.py
CHANGED
|
@@ -1,112 +1,12 @@
|
|
|
1
|
-
import
|
|
2
|
-
import os
|
|
3
|
-
import platform
|
|
4
|
-
import random
|
|
5
|
-
import shutil
|
|
6
|
-
import socketserver
|
|
7
|
-
import string
|
|
8
|
-
import subprocess
|
|
9
|
-
import sys
|
|
10
|
-
import threading
|
|
11
|
-
import time
|
|
12
|
-
import urllib.parse
|
|
13
|
-
import webbrowser
|
|
14
|
-
from typing import Any, Dict, Optional
|
|
15
|
-
from urllib.parse import urlencode
|
|
1
|
+
from typing import Optional
|
|
16
2
|
|
|
17
3
|
import click
|
|
18
|
-
import requests
|
|
19
4
|
|
|
20
|
-
from tinybird.tb.
|
|
21
|
-
from tinybird.tb.modules.
|
|
22
|
-
from tinybird.tb.modules.common import ask_for_region_interactively, get_regions
|
|
23
|
-
from tinybird.tb.modules.exceptions import CLILoginException
|
|
24
|
-
from tinybird.tb.modules.feedback_manager import FeedbackManager
|
|
5
|
+
from tinybird.tb.modules.cli import cli
|
|
6
|
+
from tinybird.tb.modules.login_common import login
|
|
25
7
|
|
|
26
|
-
SERVER_MAX_WAIT_TIME = 180
|
|
27
8
|
|
|
28
|
-
|
|
29
|
-
class AuthHandler(http.server.SimpleHTTPRequestHandler):
|
|
30
|
-
def do_GET(self):
|
|
31
|
-
# The access_token is in the URL fragment, which is not sent to the server
|
|
32
|
-
# We'll send a small HTML page that extracts the token and sends it back to the server
|
|
33
|
-
self.send_response(200)
|
|
34
|
-
self.send_header("Content-type", "text/html")
|
|
35
|
-
self.end_headers()
|
|
36
|
-
self.wfile.write(
|
|
37
|
-
"""
|
|
38
|
-
<html>
|
|
39
|
-
<head>
|
|
40
|
-
<style>
|
|
41
|
-
body {{
|
|
42
|
-
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
43
|
-
background: #f5f5f5;
|
|
44
|
-
display: flex;
|
|
45
|
-
align-items: center;
|
|
46
|
-
justify-content: center;
|
|
47
|
-
height: 100vh;
|
|
48
|
-
margin: 0;
|
|
49
|
-
}}
|
|
50
|
-
</style>
|
|
51
|
-
</head>
|
|
52
|
-
<body>
|
|
53
|
-
<script>
|
|
54
|
-
const searchParams = new URLSearchParams(window.location.search);
|
|
55
|
-
const code = searchParams.get('code');
|
|
56
|
-
const workspace = searchParams.get('workspace');
|
|
57
|
-
const region = searchParams.get('region');
|
|
58
|
-
const provider = searchParams.get('provider');
|
|
59
|
-
const host = "{auth_host}";
|
|
60
|
-
fetch('/?code=' + code, {{method: 'POST'}})
|
|
61
|
-
.then(() => {{
|
|
62
|
-
window.location.href = host + "/" + provider + "/" + region + "/cli-login?workspace=" + workspace;
|
|
63
|
-
}});
|
|
64
|
-
</script>
|
|
65
|
-
</body>
|
|
66
|
-
</html>
|
|
67
|
-
""".format(auth_host=self.server.auth_host).encode() # type: ignore
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
def do_POST(self):
|
|
71
|
-
parsed_path = urllib.parse.urlparse(self.path)
|
|
72
|
-
query_params = urllib.parse.parse_qs(parsed_path.query)
|
|
73
|
-
|
|
74
|
-
if "code" in query_params:
|
|
75
|
-
code = query_params["code"][0]
|
|
76
|
-
self.server.auth_callback(code) # type: ignore
|
|
77
|
-
self.send_response(200)
|
|
78
|
-
self.end_headers()
|
|
79
|
-
else:
|
|
80
|
-
self.send_error(400, "Missing 'code' parameter")
|
|
81
|
-
|
|
82
|
-
self.server.shutdown()
|
|
83
|
-
|
|
84
|
-
def log_message(self, format, *args):
|
|
85
|
-
# Suppress log messages
|
|
86
|
-
return
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
AUTH_SERVER_PORT = 49160
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
class AuthServer(socketserver.TCPServer):
|
|
93
|
-
allow_reuse_address = True
|
|
94
|
-
|
|
95
|
-
def __init__(self, server_address, RequestHandlerClass, auth_callback, auth_host):
|
|
96
|
-
self.auth_callback = auth_callback
|
|
97
|
-
self.auth_host = auth_host
|
|
98
|
-
super().__init__(server_address, RequestHandlerClass)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def start_server(auth_callback, auth_host):
|
|
102
|
-
with AuthServer(("", AUTH_SERVER_PORT), AuthHandler, auth_callback, auth_host) as httpd:
|
|
103
|
-
httpd.timeout = 30
|
|
104
|
-
start_time = time.time()
|
|
105
|
-
while time.time() - start_time < SERVER_MAX_WAIT_TIME: # Run for a maximum of 180 seconds
|
|
106
|
-
httpd.handle_request()
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
@cli.command()
|
|
9
|
+
@cli.command("login", help="Authenticate using the browser.")
|
|
110
10
|
@click.option(
|
|
111
11
|
"--host",
|
|
112
12
|
type=str,
|
|
@@ -135,200 +35,5 @@ def start_server(auth_callback, auth_host):
|
|
|
135
35
|
default="browser",
|
|
136
36
|
help="Set the authentication method to use. Default: browser.",
|
|
137
37
|
)
|
|
138
|
-
def
|
|
139
|
-
|
|
140
|
-
try:
|
|
141
|
-
cli_config = CLIConfig.get_project_config()
|
|
142
|
-
if not host and cli_config.get_token():
|
|
143
|
-
host = cli_config.get_host(use_defaults_if_needed=False)
|
|
144
|
-
if not host or interactive:
|
|
145
|
-
if interactive:
|
|
146
|
-
click.echo(FeedbackManager.highlight(message="» Select one region from the list below:"))
|
|
147
|
-
else:
|
|
148
|
-
click.echo(FeedbackManager.highlight(message="» No region detected, select one from the list below:"))
|
|
149
|
-
|
|
150
|
-
regions = get_regions(cli_config)
|
|
151
|
-
selected_region = ask_for_region_interactively(regions)
|
|
152
|
-
|
|
153
|
-
# If the user cancels the selection, we'll exit
|
|
154
|
-
if not selected_region:
|
|
155
|
-
sys.exit(1)
|
|
156
|
-
host = selected_region.get("api_host")
|
|
157
|
-
|
|
158
|
-
if not host:
|
|
159
|
-
host = DEFAULT_API_HOST
|
|
160
|
-
|
|
161
|
-
host = host.rstrip("/")
|
|
162
|
-
auth_host = auth_host.rstrip("/")
|
|
163
|
-
|
|
164
|
-
if method == "code":
|
|
165
|
-
display_code, one_time_code = create_one_time_code()
|
|
166
|
-
click.echo(FeedbackManager.info(message=f"First, copy your one-time code: {display_code}"))
|
|
167
|
-
click.echo(FeedbackManager.info(message="Press [Enter] to continue in the browser..."))
|
|
168
|
-
input()
|
|
169
|
-
click.echo(FeedbackManager.highlight(message="» Opening browser for authentication..."))
|
|
170
|
-
params = {
|
|
171
|
-
"apiHost": host,
|
|
172
|
-
"code": one_time_code,
|
|
173
|
-
"method": "code",
|
|
174
|
-
}
|
|
175
|
-
auth_url = f"{auth_host}/api/cli-login?{urlencode(params)}"
|
|
176
|
-
open_url(auth_url)
|
|
177
|
-
click.echo(
|
|
178
|
-
FeedbackManager.info(message="\nIf browser does not open, please open the following URL manually:")
|
|
179
|
-
)
|
|
180
|
-
click.echo(FeedbackManager.info(message=auth_url))
|
|
181
|
-
|
|
182
|
-
def poll_for_tokens():
|
|
183
|
-
while True:
|
|
184
|
-
params = {
|
|
185
|
-
"apiHost": host,
|
|
186
|
-
"cliCode": one_time_code,
|
|
187
|
-
"method": "code",
|
|
188
|
-
}
|
|
189
|
-
response = requests.get(f"{auth_host}/api/cli-login?{urlencode(params)}")
|
|
190
|
-
|
|
191
|
-
try:
|
|
192
|
-
if response.status_code == 200:
|
|
193
|
-
data = response.json()
|
|
194
|
-
user_token = data.get("user_token", "")
|
|
195
|
-
workspace_token = data.get("workspace_token", "")
|
|
196
|
-
if user_token and workspace_token:
|
|
197
|
-
authenticate_with_tokens(data, host, cli_config)
|
|
198
|
-
break
|
|
199
|
-
except Exception:
|
|
200
|
-
pass
|
|
201
|
-
|
|
202
|
-
time.sleep(2)
|
|
203
|
-
|
|
204
|
-
poll_for_tokens()
|
|
205
|
-
return
|
|
206
|
-
|
|
207
|
-
auth_event = threading.Event()
|
|
208
|
-
auth_code: list[str] = [] # Using a list to store the code, as it's mutable
|
|
209
|
-
|
|
210
|
-
def auth_callback(code):
|
|
211
|
-
auth_code.append(code)
|
|
212
|
-
auth_event.set()
|
|
213
|
-
|
|
214
|
-
click.echo(FeedbackManager.highlight(message="» Opening browser for authentication..."))
|
|
215
|
-
# Start the local server in a separate thread
|
|
216
|
-
server_thread = threading.Thread(target=start_server, args=(auth_callback, auth_host))
|
|
217
|
-
server_thread.daemon = True
|
|
218
|
-
server_thread.start()
|
|
219
|
-
|
|
220
|
-
# Open the browser to the auth page
|
|
221
|
-
params = {
|
|
222
|
-
"apiHost": host,
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
if workspace:
|
|
226
|
-
params["workspace"] = workspace
|
|
227
|
-
|
|
228
|
-
auth_url = f"{auth_host}/api/cli-login?{urlencode(params)}"
|
|
229
|
-
open_url(auth_url)
|
|
230
|
-
|
|
231
|
-
click.echo(FeedbackManager.info(message="\nIf browser does not open, please open the following URL manually:"))
|
|
232
|
-
click.echo(FeedbackManager.info(message=auth_url))
|
|
233
|
-
|
|
234
|
-
# Wait for the authentication to complete or timeout
|
|
235
|
-
if auth_event.wait(timeout=SERVER_MAX_WAIT_TIME): # Wait for up to 180 seconds
|
|
236
|
-
params = {}
|
|
237
|
-
params["code"] = auth_code[0]
|
|
238
|
-
response = requests.get(
|
|
239
|
-
f"{auth_host}/api/cli-login?{urlencode(params)}",
|
|
240
|
-
)
|
|
241
|
-
|
|
242
|
-
data = response.json()
|
|
243
|
-
authenticate_with_tokens(data, host, cli_config)
|
|
244
|
-
else:
|
|
245
|
-
raise Exception("Authentication failed or timed out.")
|
|
246
|
-
except Exception as e:
|
|
247
|
-
raise CLILoginException(FeedbackManager.error(message=str(e)))
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
def _running_in_wsl() -> bool:
|
|
251
|
-
"""Return True when Python is executing inside a WSL distro."""
|
|
252
|
-
# Fast positive check (modern WSL always sets at least one of these):
|
|
253
|
-
if "WSL_DISTRO_NAME" in os.environ or "WSL_INTEROP" in os.environ:
|
|
254
|
-
return True
|
|
255
|
-
|
|
256
|
-
# Fall back to kernel /proc data
|
|
257
|
-
release = platform.uname().release.lower()
|
|
258
|
-
if "microsoft" in release: # covers stock WSL kernels
|
|
259
|
-
return True
|
|
260
|
-
try:
|
|
261
|
-
if "microsoft" in open("/proc/version").read().lower():
|
|
262
|
-
return True
|
|
263
|
-
except FileNotFoundError:
|
|
264
|
-
pass
|
|
265
|
-
return False
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
def open_url(url: str, *, new_tab: bool = False) -> bool:
|
|
269
|
-
# 1. Try the standard library first on CPython ≥ 3.11 this already
|
|
270
|
-
# recognises WSL and fires up the Windows default browser for us.
|
|
271
|
-
try:
|
|
272
|
-
wb: Any = webbrowser.get() # mypy: Any for Py < 3.10
|
|
273
|
-
if new_tab:
|
|
274
|
-
if wb.open_new_tab(url):
|
|
275
|
-
return True
|
|
276
|
-
else:
|
|
277
|
-
if wb.open(url):
|
|
278
|
-
return True
|
|
279
|
-
except webbrowser.Error:
|
|
280
|
-
pass # keep going
|
|
281
|
-
|
|
282
|
-
# 2. Inside WSL, prefer `wslview` if the user has it (wslu package).
|
|
283
|
-
if _running_in_wsl() and shutil.which("wslview"):
|
|
284
|
-
subprocess.Popen(["wslview", url])
|
|
285
|
-
return True
|
|
286
|
-
|
|
287
|
-
# 3. Secondary WSL fallback use Windows **start** through cmd.exe.
|
|
288
|
-
# Empty "" argument is required so long URLs are not treated as a window title.
|
|
289
|
-
if _running_in_wsl():
|
|
290
|
-
subprocess.Popen(["cmd.exe", "/c", "start", "", url])
|
|
291
|
-
return True
|
|
292
|
-
|
|
293
|
-
# 4. Unix last-ditch fallback xdg-open (most minimal container images have it)
|
|
294
|
-
if shutil.which("xdg-open"):
|
|
295
|
-
subprocess.Popen(["xdg-open", url])
|
|
296
|
-
return True
|
|
297
|
-
|
|
298
|
-
# 5. If everything failed, let the caller know.
|
|
299
|
-
return False
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
def create_one_time_code():
|
|
303
|
-
"""Create a random one-time code for the authentication process in the format of A2C4-D2G4 (only uppercase letters and digits)"""
|
|
304
|
-
seperator = "-"
|
|
305
|
-
full_code = "".join(random.choices(string.ascii_uppercase + string.digits, k=8))
|
|
306
|
-
parts = [full_code[:4], full_code[4:]]
|
|
307
|
-
return seperator.join(parts), full_code
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
def authenticate_with_tokens(data: Dict[str, Any], host: Optional[str], cli_config: CLIConfig):
|
|
311
|
-
cli_config.set_token(data.get("workspace_token", ""))
|
|
312
|
-
host = host or data.get("api_host", "")
|
|
313
|
-
cli_config.set_token_for_host(data.get("workspace_token", ""), host)
|
|
314
|
-
cli_config.set_user_token(data.get("user_token", ""))
|
|
315
|
-
cli_config.set_host(host)
|
|
316
|
-
ws = cli_config.get_client(token=data.get("workspace_token", ""), host=host).workspace_info(version="v1")
|
|
317
|
-
for k in ("id", "name", "user_email", "user_id", "scope"):
|
|
318
|
-
if k in ws:
|
|
319
|
-
cli_config[k] = ws[k]
|
|
320
|
-
|
|
321
|
-
path = os.path.join(os.getcwd(), ".tinyb")
|
|
322
|
-
cli_config.persist_to_file(override_with_path=path)
|
|
323
|
-
|
|
324
|
-
auth_info: Dict[str, Any] = cli_config.get_user_client().check_auth_login()
|
|
325
|
-
if not auth_info.get("is_valid", False):
|
|
326
|
-
raise Exception(FeedbackManager.error_auth_login_not_valid(host=cli_config.get_host()))
|
|
327
|
-
|
|
328
|
-
if not auth_info.get("is_user", False):
|
|
329
|
-
raise Exception(FeedbackManager.error_auth_login_not_user(host=cli_config.get_host()))
|
|
330
|
-
|
|
331
|
-
click.echo(FeedbackManager.gray(message="\nWorkspace: ") + FeedbackManager.info(message=ws["name"]))
|
|
332
|
-
click.echo(FeedbackManager.gray(message="User: ") + FeedbackManager.info(message=ws["user_email"]))
|
|
333
|
-
click.echo(FeedbackManager.gray(message="Host: ") + FeedbackManager.info(message=host))
|
|
334
|
-
click.echo(FeedbackManager.success(message="\n✓ Authentication successful!"))
|
|
38
|
+
def login_cmd(host: Optional[str], auth_host: str, workspace: str, interactive: bool, method: str):
|
|
39
|
+
login(host, auth_host, workspace, interactive, method)
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import http.server
|
|
2
|
+
import os
|
|
3
|
+
import platform
|
|
4
|
+
import random
|
|
5
|
+
import shutil
|
|
6
|
+
import socketserver
|
|
7
|
+
import string
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import webbrowser
|
|
14
|
+
from typing import Any, Dict, Optional
|
|
15
|
+
from urllib.parse import urlencode
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
import requests
|
|
19
|
+
|
|
20
|
+
from tinybird.tb.config import DEFAULT_API_HOST
|
|
21
|
+
from tinybird.tb.modules.common import ask_for_region_interactively, get_regions
|
|
22
|
+
from tinybird.tb.modules.config import CLIConfig
|
|
23
|
+
from tinybird.tb.modules.exceptions import CLILoginException
|
|
24
|
+
from tinybird.tb.modules.feedback_manager import FeedbackManager
|
|
25
|
+
|
|
26
|
+
SERVER_MAX_WAIT_TIME = 180
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AuthHandler(http.server.SimpleHTTPRequestHandler):
|
|
30
|
+
def do_GET(self):
|
|
31
|
+
# The access_token is in the URL fragment, which is not sent to the server
|
|
32
|
+
# We'll send a small HTML page that extracts the token and sends it back to the server
|
|
33
|
+
self.send_response(200)
|
|
34
|
+
self.send_header("Content-type", "text/html")
|
|
35
|
+
self.end_headers()
|
|
36
|
+
self.wfile.write(
|
|
37
|
+
"""
|
|
38
|
+
<html>
|
|
39
|
+
<head>
|
|
40
|
+
<style>
|
|
41
|
+
body {{
|
|
42
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
43
|
+
background: #f5f5f5;
|
|
44
|
+
display: flex;
|
|
45
|
+
align-items: center;
|
|
46
|
+
justify-content: center;
|
|
47
|
+
height: 100vh;
|
|
48
|
+
margin: 0;
|
|
49
|
+
}}
|
|
50
|
+
</style>
|
|
51
|
+
</head>
|
|
52
|
+
<body>
|
|
53
|
+
<script>
|
|
54
|
+
const searchParams = new URLSearchParams(window.location.search);
|
|
55
|
+
const code = searchParams.get('code');
|
|
56
|
+
const workspace = searchParams.get('workspace');
|
|
57
|
+
const region = searchParams.get('region');
|
|
58
|
+
const provider = searchParams.get('provider');
|
|
59
|
+
const host = "{auth_host}";
|
|
60
|
+
fetch('/?code=' + code, {{method: 'POST'}})
|
|
61
|
+
.then(() => {{
|
|
62
|
+
window.location.href = host + "/" + provider + "/" + region + "/cli-login?workspace=" + workspace;
|
|
63
|
+
}});
|
|
64
|
+
</script>
|
|
65
|
+
</body>
|
|
66
|
+
</html>
|
|
67
|
+
""".format(auth_host=self.server.auth_host).encode() # type: ignore
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def do_POST(self):
|
|
71
|
+
parsed_path = urllib.parse.urlparse(self.path)
|
|
72
|
+
query_params = urllib.parse.parse_qs(parsed_path.query)
|
|
73
|
+
|
|
74
|
+
if "code" in query_params:
|
|
75
|
+
code = query_params["code"][0]
|
|
76
|
+
self.server.auth_callback(code) # type: ignore
|
|
77
|
+
self.send_response(200)
|
|
78
|
+
self.end_headers()
|
|
79
|
+
else:
|
|
80
|
+
self.send_error(400, "Missing 'code' parameter")
|
|
81
|
+
|
|
82
|
+
self.server.shutdown()
|
|
83
|
+
|
|
84
|
+
def log_message(self, format, *args):
|
|
85
|
+
# Suppress log messages
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
AUTH_SERVER_PORT = 49160
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class AuthServer(socketserver.TCPServer):
|
|
93
|
+
allow_reuse_address = True
|
|
94
|
+
|
|
95
|
+
def __init__(self, server_address, RequestHandlerClass, auth_callback, auth_host):
|
|
96
|
+
self.auth_callback = auth_callback
|
|
97
|
+
self.auth_host = auth_host
|
|
98
|
+
super().__init__(server_address, RequestHandlerClass)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def start_server(auth_callback, auth_host):
|
|
102
|
+
with AuthServer(("", AUTH_SERVER_PORT), AuthHandler, auth_callback, auth_host) as httpd:
|
|
103
|
+
httpd.timeout = 30
|
|
104
|
+
start_time = time.time()
|
|
105
|
+
while time.time() - start_time < SERVER_MAX_WAIT_TIME: # Run for a maximum of 180 seconds
|
|
106
|
+
httpd.handle_request()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def login(
|
|
110
|
+
host: Optional[str],
|
|
111
|
+
auth_host: str = "https://cloud.tinybird.co",
|
|
112
|
+
workspace: Optional[str] = None,
|
|
113
|
+
interactive: bool = False,
|
|
114
|
+
method: str = "browser",
|
|
115
|
+
):
|
|
116
|
+
try:
|
|
117
|
+
cli_config = CLIConfig.get_project_config()
|
|
118
|
+
if not host and cli_config.get_token():
|
|
119
|
+
host = cli_config.get_host(use_defaults_if_needed=False)
|
|
120
|
+
if not host or interactive:
|
|
121
|
+
if interactive:
|
|
122
|
+
click.echo(FeedbackManager.highlight(message="» Select one region from the list below:"))
|
|
123
|
+
else:
|
|
124
|
+
click.echo(FeedbackManager.highlight(message="» No region detected, select one from the list below:"))
|
|
125
|
+
|
|
126
|
+
regions = get_regions(cli_config)
|
|
127
|
+
selected_region = ask_for_region_interactively(regions)
|
|
128
|
+
|
|
129
|
+
# If the user cancels the selection, we'll exit
|
|
130
|
+
if not selected_region:
|
|
131
|
+
sys.exit(1)
|
|
132
|
+
host = selected_region.get("api_host")
|
|
133
|
+
|
|
134
|
+
if not host:
|
|
135
|
+
host = DEFAULT_API_HOST
|
|
136
|
+
|
|
137
|
+
host = host.rstrip("/")
|
|
138
|
+
auth_host = auth_host.rstrip("/")
|
|
139
|
+
|
|
140
|
+
if method == "code":
|
|
141
|
+
display_code, one_time_code = create_one_time_code()
|
|
142
|
+
click.echo(FeedbackManager.info(message=f"First, copy your one-time code: {display_code}"))
|
|
143
|
+
click.echo(FeedbackManager.info(message="Press [Enter] to continue in the browser..."))
|
|
144
|
+
input()
|
|
145
|
+
click.echo(FeedbackManager.highlight(message="» Opening browser for authentication..."))
|
|
146
|
+
params = {
|
|
147
|
+
"apiHost": host,
|
|
148
|
+
"code": one_time_code,
|
|
149
|
+
"method": "code",
|
|
150
|
+
}
|
|
151
|
+
auth_url = f"{auth_host}/api/cli-login?{urlencode(params)}"
|
|
152
|
+
open_url(auth_url)
|
|
153
|
+
click.echo(
|
|
154
|
+
FeedbackManager.info(message="\nIf browser does not open, please open the following URL manually:")
|
|
155
|
+
)
|
|
156
|
+
click.echo(FeedbackManager.info(message=auth_url))
|
|
157
|
+
|
|
158
|
+
def poll_for_tokens():
|
|
159
|
+
while True:
|
|
160
|
+
params = {
|
|
161
|
+
"apiHost": host,
|
|
162
|
+
"cliCode": one_time_code,
|
|
163
|
+
"method": "code",
|
|
164
|
+
}
|
|
165
|
+
response = requests.get(f"{auth_host}/api/cli-login?{urlencode(params)}")
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
if response.status_code == 200:
|
|
169
|
+
data = response.json()
|
|
170
|
+
user_token = data.get("user_token", "")
|
|
171
|
+
workspace_token = data.get("workspace_token", "")
|
|
172
|
+
if user_token and workspace_token:
|
|
173
|
+
authenticate_with_tokens(data, host, cli_config)
|
|
174
|
+
break
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
time.sleep(2)
|
|
179
|
+
|
|
180
|
+
poll_for_tokens()
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
auth_event = threading.Event()
|
|
184
|
+
auth_code: list[str] = [] # Using a list to store the code, as it's mutable
|
|
185
|
+
|
|
186
|
+
def auth_callback(code):
|
|
187
|
+
auth_code.append(code)
|
|
188
|
+
auth_event.set()
|
|
189
|
+
|
|
190
|
+
click.echo(FeedbackManager.highlight(message="» Opening browser for authentication..."))
|
|
191
|
+
# Start the local server in a separate thread
|
|
192
|
+
server_thread = threading.Thread(target=start_server, args=(auth_callback, auth_host))
|
|
193
|
+
server_thread.daemon = True
|
|
194
|
+
server_thread.start()
|
|
195
|
+
|
|
196
|
+
# Open the browser to the auth page
|
|
197
|
+
params = {
|
|
198
|
+
"apiHost": host,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if workspace:
|
|
202
|
+
params["workspace"] = workspace
|
|
203
|
+
|
|
204
|
+
auth_url = f"{auth_host}/api/cli-login?{urlencode(params)}"
|
|
205
|
+
open_url(auth_url)
|
|
206
|
+
|
|
207
|
+
click.echo(FeedbackManager.info(message="\nIf browser does not open, please open the following URL manually:"))
|
|
208
|
+
click.echo(FeedbackManager.info(message=auth_url))
|
|
209
|
+
|
|
210
|
+
# Wait for the authentication to complete or timeout
|
|
211
|
+
if auth_event.wait(timeout=SERVER_MAX_WAIT_TIME): # Wait for up to 180 seconds
|
|
212
|
+
params = {}
|
|
213
|
+
params["code"] = auth_code[0]
|
|
214
|
+
response = requests.get(
|
|
215
|
+
f"{auth_host}/api/cli-login?{urlencode(params)}",
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
data = response.json()
|
|
219
|
+
authenticate_with_tokens(data, host, cli_config)
|
|
220
|
+
else:
|
|
221
|
+
raise Exception("Authentication failed or timed out.")
|
|
222
|
+
except Exception as e:
|
|
223
|
+
raise CLILoginException(FeedbackManager.error(message=str(e)))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _running_in_wsl() -> bool:
|
|
227
|
+
"""Return True when Python is executing inside a WSL distro."""
|
|
228
|
+
# Fast positive check (modern WSL always sets at least one of these):
|
|
229
|
+
if "WSL_DISTRO_NAME" in os.environ or "WSL_INTEROP" in os.environ:
|
|
230
|
+
return True
|
|
231
|
+
|
|
232
|
+
# Fall back to kernel /proc data
|
|
233
|
+
release = platform.uname().release.lower()
|
|
234
|
+
if "microsoft" in release: # covers stock WSL kernels
|
|
235
|
+
return True
|
|
236
|
+
try:
|
|
237
|
+
if "microsoft" in open("/proc/version").read().lower():
|
|
238
|
+
return True
|
|
239
|
+
except FileNotFoundError:
|
|
240
|
+
pass
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def open_url(url: str, *, new_tab: bool = False) -> bool:
|
|
245
|
+
# 1. Try the standard library first on CPython ≥ 3.11 this already
|
|
246
|
+
# recognises WSL and fires up the Windows default browser for us.
|
|
247
|
+
try:
|
|
248
|
+
wb: Any = webbrowser.get() # mypy: Any for Py < 3.10
|
|
249
|
+
if new_tab:
|
|
250
|
+
if wb.open_new_tab(url):
|
|
251
|
+
return True
|
|
252
|
+
else:
|
|
253
|
+
if wb.open(url):
|
|
254
|
+
return True
|
|
255
|
+
except webbrowser.Error:
|
|
256
|
+
pass # keep going
|
|
257
|
+
|
|
258
|
+
# 2. Inside WSL, prefer `wslview` if the user has it (wslu package).
|
|
259
|
+
if _running_in_wsl() and shutil.which("wslview"):
|
|
260
|
+
subprocess.Popen(["wslview", url])
|
|
261
|
+
return True
|
|
262
|
+
|
|
263
|
+
# 3. Secondary WSL fallback use Windows **start** through cmd.exe.
|
|
264
|
+
# Empty "" argument is required so long URLs are not treated as a window title.
|
|
265
|
+
if _running_in_wsl():
|
|
266
|
+
subprocess.Popen(["cmd.exe", "/c", "start", "", url])
|
|
267
|
+
return True
|
|
268
|
+
|
|
269
|
+
# 4. Unix last-ditch fallback xdg-open (most minimal container images have it)
|
|
270
|
+
if shutil.which("xdg-open"):
|
|
271
|
+
subprocess.Popen(["xdg-open", url])
|
|
272
|
+
return True
|
|
273
|
+
|
|
274
|
+
# 5. If everything failed, let the caller know.
|
|
275
|
+
return False
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def create_one_time_code():
|
|
279
|
+
"""Create a random one-time code for the authentication process in the format of A2C4-D2G4 (only uppercase letters and digits)"""
|
|
280
|
+
seperator = "-"
|
|
281
|
+
full_code = "".join(random.choices(string.ascii_uppercase + string.digits, k=8))
|
|
282
|
+
parts = [full_code[:4], full_code[4:]]
|
|
283
|
+
return seperator.join(parts), full_code
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def authenticate_with_tokens(data: Dict[str, Any], host: Optional[str], cli_config: CLIConfig):
|
|
287
|
+
cli_config.set_token(data.get("workspace_token", ""))
|
|
288
|
+
host = host or data.get("api_host", "")
|
|
289
|
+
cli_config.set_token_for_host(data.get("workspace_token", ""), host)
|
|
290
|
+
cli_config.set_user_token(data.get("user_token", ""))
|
|
291
|
+
cli_config.set_host(host)
|
|
292
|
+
ws = cli_config.get_client(token=data.get("workspace_token", ""), host=host).workspace_info(version="v1")
|
|
293
|
+
for k in ("id", "name", "user_email", "user_id", "scope"):
|
|
294
|
+
if k in ws:
|
|
295
|
+
cli_config[k] = ws[k]
|
|
296
|
+
|
|
297
|
+
path = os.path.join(os.getcwd(), ".tinyb")
|
|
298
|
+
cli_config.persist_to_file(override_with_path=path)
|
|
299
|
+
|
|
300
|
+
auth_info: Dict[str, Any] = cli_config.get_user_client().check_auth_login()
|
|
301
|
+
if not auth_info.get("is_valid", False):
|
|
302
|
+
raise Exception(FeedbackManager.error_auth_login_not_valid(host=cli_config.get_host()))
|
|
303
|
+
|
|
304
|
+
if not auth_info.get("is_user", False):
|
|
305
|
+
raise Exception(FeedbackManager.error_auth_login_not_user(host=cli_config.get_host()))
|
|
306
|
+
|
|
307
|
+
click.echo(FeedbackManager.gray(message="\nWorkspace: ") + FeedbackManager.info(message=ws["name"]))
|
|
308
|
+
click.echo(FeedbackManager.gray(message="User: ") + FeedbackManager.info(message=ws["user_email"]))
|
|
309
|
+
click.echo(FeedbackManager.gray(message="Host: ") + FeedbackManager.info(message=host))
|
|
310
|
+
click.echo(FeedbackManager.success(message="\n✓ Authentication successful!"))
|