inferenceport-cli 1.0.0__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.
ipx/commands/auth.py ADDED
@@ -0,0 +1,269 @@
1
+ """ipx auth - sign in, sign out, inspect the session and choose an organization context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ import sys
7
+ import threading
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ import click
11
+ from rich.text import Text
12
+
13
+ from ipx import auth as A
14
+ from ipx import ui
15
+ from ipx.app import App, pass_app
16
+ from ipx.clickx import IpxGroup
17
+ from ipx.errors import ApiError, IpxError
18
+
19
+
20
+ @click.group("auth", cls=IpxGroup, section="Account", short_help="Sign in/out, session status and organization context")
21
+ def group() -> None:
22
+ """Manage your InferencePort AI identity.
23
+
24
+ Sign in with email and password, a browser OAuth flow (GitHub, Google, Microsoft, Hugging Face), your
25
+ organization's single sign-on, or a Lightning API key. `ipx auth context` selects the organization that
26
+ `ipx org ...` commands act on and that cloud usage is billed to.
27
+ """
28
+
29
+
30
+ def _read_password(from_stdin: bool, given: Optional[str]) -> str:
31
+ if given:
32
+ return given
33
+ if from_stdin:
34
+ return sys.stdin.readline().rstrip("\r\n")
35
+ return ui.ask("Password", password=True)
36
+
37
+
38
+ @group.command("login", examples=[
39
+ "ipx auth login",
40
+ "ipx auth login --email me@example.com",
41
+ "ipx auth login --provider github",
42
+ "ipx auth login --sso me@company.com",
43
+ "ipx auth login --api-key ipa_live_xxxxxxxx",
44
+ "echo $PASSWORD | ipx auth login --email me@example.com --password-stdin",
45
+ ])
46
+ @click.option("--email", help="Email address for password sign-in.")
47
+ @click.option("--password", help="Password (prefer --password-stdin; avoid shell history).")
48
+ @click.option("--password-stdin", is_flag=True, help="Read the password from standard input.")
49
+ @click.option("--api-key", help="Sign in with a Lightning API key (ipa_live_...).")
50
+ @click.option("--provider", type=click.Choice(sorted(A.OAUTH_PROVIDERS)), help="OAuth provider (opens your browser).")
51
+ @click.option("--sso", "sso_email", metavar="EMAIL", help="Single sign-on using your work email.")
52
+ @click.option("--paste", is_flag=True, help="Paste the browser redirect URL instead of using the local listener.")
53
+ @click.option("--no-browser", is_flag=True, help="Print the sign-in URL instead of opening a browser.")
54
+ @pass_app
55
+ def login(app: App, email, password, password_stdin, api_key, provider, sso_email, paste, no_browser) -> None:
56
+ """Sign in to InferencePort AI."""
57
+ method = None
58
+ if api_key:
59
+ method = "api_key"
60
+ elif provider:
61
+ method = "oauth"
62
+ elif sso_email:
63
+ method = "sso"
64
+ elif email or password_stdin or password:
65
+ method = "password"
66
+ else:
67
+ ui.print_banner("sign in")
68
+ method = ui.choose("How would you like to sign in?", [
69
+ ("password", "Email & password"),
70
+ ("github", "GitHub"),
71
+ ("google", "Google"),
72
+ ("microsoft", "Microsoft"),
73
+ ("huggingface", "Hugging Face"),
74
+ ("sso", "Single sign-on (work email)"),
75
+ ("api_key", "API key"),
76
+ ])
77
+ if method in A.OAUTH_PROVIDERS:
78
+ provider, method = method, "oauth"
79
+
80
+ if method == "api_key":
81
+ key = api_key or ui.ask("API key (ipa_live_…)", password=True)
82
+ with ui.status("Verifying API key…"):
83
+ me = A.api_key_login(app.cfg, app.creds, key)
84
+ who = ((me.get("identity") or {}).get("email")) or "your account"
85
+ _signed_in(app, who, "API key")
86
+ return
87
+
88
+ if method == "password":
89
+ email = email or ui.ask("Email")
90
+ pw = _read_password(password_stdin, password)
91
+ with ui.status("Signing in…"):
92
+ session = A.password_login(app.cfg, app.creds, email, pw)
93
+ _signed_in(app, (session.get("user") or {}).get("email") or email, "Email & password")
94
+ return
95
+
96
+ # browser based: oauth / sso
97
+ if method == "sso":
98
+ sso_email = sso_email or ui.ask("Work email")
99
+ build = lambda redirect: A.sso_url(app.cfg, sso_email, redirect) # noqa: E731
100
+ label = "Single sign-on"
101
+ else:
102
+ build = lambda redirect: A.oauth_url(app.cfg, provider, redirect) # noqa: E731
103
+ label = A.OAUTH_PROVIDERS[provider]
104
+
105
+ if paste:
106
+ url = build(A.CALLBACK_PAGE)
107
+ ui.console.print(f"\n[accent]1.[/accent] Open this link and sign in:\n\n [underline]{url}[/underline]\n")
108
+ ui.console.print("[accent]2.[/accent] When the page finishes, copy the [bold]full address[/bold] from your browser and paste it here.\n")
109
+ session = A.parse_callback(ui.ask("Redirect URL"))
110
+ else:
111
+ def show(url: str) -> None:
112
+ ui.console.print(f"[muted]Opening your browser to sign in with {label}…[/muted]")
113
+ ui.console.print(f"[muted]If it does not open, visit:[/muted]\n [underline]{url}[/underline]\n")
114
+
115
+ def nudge() -> None:
116
+ ui.console.print("[muted]Still waiting. If the browser is on another machine or never returns here, press Ctrl+C and "
117
+ "run again with --paste.[/muted]")
118
+
119
+ timer = threading.Timer(30.0, nudge)
120
+ timer.daemon = True
121
+ timer.start()
122
+ try:
123
+ with ui.status(f"Waiting for {label} sign-in in your browser… (Ctrl+C to cancel)"):
124
+ session = A.browser_login(app.cfg, build, open_browser=not no_browser, on_url=show)
125
+ finally:
126
+ timer.cancel()
127
+ with ui.status("Finishing sign-in…"):
128
+ user = A.complete_session(app.cfg, app.creds, session)
129
+ _signed_in(app, user.get("email") or "your account", label)
130
+
131
+
132
+ def _signed_in(app: App, who: str, via: str) -> None:
133
+ if app.json:
134
+ ui.emit_json({"signed_in": True, "email": who, "method": via})
135
+ return
136
+ ui.ok(f"Signed in as [bold]{who}[/bold] [muted]via {via}[/muted]")
137
+ if not app.cfg.org:
138
+ ui.dim("Tip: pick an organization with `ipx auth context <org>` · try `ipx gen chat` to start chatting.")
139
+
140
+
141
+ @group.command("logout", short_help="Sign out and forget stored credentials")
142
+ @pass_app
143
+ def logout(app: App) -> None:
144
+ """Sign out and remove stored credentials (the org context is kept)."""
145
+ if not app.creds.data:
146
+ ui.info("You were not signed in.")
147
+ return
148
+ with ui.status("Signing out…"):
149
+ A.sign_out(app.cfg, app.creds)
150
+ app.out({"signed_in": False}, lambda _: ui.ok("Signed out. Local credentials removed."))
151
+
152
+
153
+ @group.command("status", short_help="Show who you are signed in as")
154
+ @pass_app
155
+ def status(app: App) -> None:
156
+ """Show the active identity, session expiry, plan and organization context."""
157
+ c = app.creds
158
+ if not c.signed_in:
159
+ app.out({"signed_in": False}, lambda _: ui.fail("Not signed in.", "Run `ipx auth login`."))
160
+ sys.exit(1)
161
+ plan: Dict[str, Any] = {}
162
+ try:
163
+ plan = app.api.get("/subscription", org=False) or {}
164
+ except IpxError:
165
+ pass
166
+ exp = None
167
+ if c.auth_type == "jwt" and c.expires_at:
168
+ exp = dt.datetime.fromtimestamp(c.expires_at).strftime("%Y-%m-%d %H:%M")
169
+ data = {
170
+ "signed_in": True,
171
+ "email": plan.get("email") or c.email,
172
+ "auth_type": c.auth_type,
173
+ "plan": plan.get("plan_name") or plan.get("plan_key"),
174
+ "session_expires": exp,
175
+ "org": app.org,
176
+ "api": app.cfg.base_url,
177
+ }
178
+
179
+ def render(d: Dict[str, Any]) -> None:
180
+ rows = [
181
+ ("Account", d["email"]),
182
+ ("Sign-in", "API key" if d["auth_type"] == "api_key" else (c.data.get("provider") or "session").title()),
183
+ ("Plan", d["plan"]),
184
+ ("Session", f"refreshes automatically · token valid until {exp}" if exp else ("never expires (revoke in console)" if d["auth_type"] == "api_key" else None)),
185
+ ("Organization", Text(d["org"], style="bold " + ui.VIOLET) if d["org"] else Text("personal (no org context)", style="muted")),
186
+ ("API", d["api"]),
187
+ ]
188
+ ui.console.print(ui.kv_panel("Session", rows))
189
+
190
+ app.out(data, render)
191
+
192
+
193
+ group.alias("whoami", "status")
194
+ group.alias("signout", "logout")
195
+
196
+
197
+ @group.command("token", short_help="Print the current access token")
198
+ @pass_app
199
+ def token(app: App) -> None:
200
+ """Print the bearer token for use with curl or other tools. Treat it like a password."""
201
+ app.require_login()
202
+ if app.creds.expiring():
203
+ A.refresh(app.cfg, app.creds)
204
+ sys.stdout.write((app.creds.access_token or "") + "\n")
205
+
206
+
207
+ def _list_orgs(app: App) -> List[Dict[str, Any]]:
208
+ app.require_jwt("Listing organizations")
209
+ data = app.api.get("/v1/orgs", org=False) or {}
210
+ return list(data.get("orgs") or [])
211
+
212
+
213
+ def resolve_org(app: App, ref: str) -> Dict[str, Any]:
214
+ ref_l = ref.strip().lower()
215
+ orgs = _list_orgs(app)
216
+ for o in orgs:
217
+ if ref_l in (str(o.get("slug", "")).lower(), str(o.get("id", "")).lower()):
218
+ return o
219
+ for o in orgs:
220
+ if ref_l == str(o.get("name", "")).lower():
221
+ return o
222
+ names = ", ".join(o.get("slug", "?") for o in orgs) or "none"
223
+ raise IpxError(f"You are not a member of an organization matching “{ref}”.", f"Your organizations: {names}")
224
+
225
+
226
+ @group.command("context", examples=["ipx auth context", "ipx auth context acme", "ipx auth context --clear"],
227
+ short_help="Show or set the active organization")
228
+ @click.argument("org", required=False)
229
+ @click.option("--clear", is_flag=True, help="Return to your personal account.")
230
+ @pass_app
231
+ def context(app: App, org: Optional[str], clear: bool) -> None:
232
+ """Show or set the organization that org commands and cloud usage apply to.
233
+
234
+ ORG may be a slug, id or name. With no argument, shows the current context (and offers a picker in a terminal).
235
+ """
236
+ if clear:
237
+ app.cfg.set_org(None)
238
+ app.out({"org": None}, lambda _: ui.ok("Context cleared — using your personal account."))
239
+ return
240
+ if not org:
241
+ current = app.cfg.org
242
+ if app.json or not ui.is_tty() or not app.creds.signed_in or app.creds.auth_type == "api_key":
243
+ app.out({"org": current}, lambda _: ui.console.print(
244
+ f"Organization context: [bold {ui.VIOLET}]{current}[/]" if current else "[muted]No organization context (personal account).[/muted]"))
245
+ return
246
+ orgs = _list_orgs(app)
247
+ if not orgs:
248
+ ui.info("You are not a member of any organization yet. Create one with `ipx org create`.")
249
+ return
250
+ opts = [("", "Personal account")] + [(o["slug"], f"{o['name']} [muted]({o['slug']} · {o.get('role')})[/muted]") for o in orgs]
251
+ default = next((i for i, (v, _) in enumerate(opts) if v == current), 0)
252
+ org = ui.choose("Which context should ipx use?", opts, default)
253
+ if not org:
254
+ app.cfg.set_org(None)
255
+ ui.ok("Using your personal account.")
256
+ return
257
+ if app.creds.auth_type == "api_key":
258
+ try:
259
+ app.api.get(f"/v1/orgs/{org.lower()}", org=False)
260
+ except ApiError as e:
261
+ raise IpxError(f"Could not access organization “{org}”: {e.message}", "Check the slug and that your API key belongs to it.")
262
+ slug = org.lower()
263
+ info: Dict[str, Any] = {"slug": slug, "name": slug}
264
+ else:
265
+ info = resolve_org(app, org)
266
+ slug = info["slug"]
267
+ app.cfg.set_org(slug)
268
+ app.out({"org": slug, "role": info.get("role")}, lambda _: ui.ok(
269
+ f"Context set to [bold {ui.VIOLET}]{info.get('name', slug)}[/] [muted]({slug}{' · ' + info['role'] if info.get('role') else ''})[/muted]"))