upscaler-cli 0.2.2.dev6560__py3-none-any.whl → 0.2.2.dev6601__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.
src/auth/token_store.py CHANGED
@@ -15,6 +15,7 @@ from pathlib import Path
15
15
  from typing import Optional
16
16
 
17
17
  from src.auth.encryption import decrypt, derive_key, encrypt
18
+ from src.profile import ensure_profile_dir, get_profile_dir
18
19
 
19
20
 
20
21
  @dataclass
@@ -31,14 +32,26 @@ class TokenData:
31
32
 
32
33
 
33
34
  class TokenStore:
34
- """Encrypted token store at ~/.upscaler/."""
35
+ """Encrypted token store at ~/.upscaler/profiles/{profile}/."""
35
36
 
36
37
  SALT_FILE = ".salt"
37
38
  TOKEN_FILE = "tokens.enc"
38
39
  SALT_SIZE = 16
39
40
 
40
- def __init__(self, config_dir: Optional[str] = None):
41
- self.config_dir = Path(config_dir or os.path.expanduser("~/.upscaler"))
41
+ def __init__(
42
+ self,
43
+ config_dir: Optional[str] = None,
44
+ profile: Optional[str] = None,
45
+ ):
46
+ """Open the token store for `profile` (or the resolved active profile).
47
+
48
+ Passing `config_dir` overrides profile resolution and points at an
49
+ explicit directory; used by tests.
50
+ """
51
+ if config_dir is not None:
52
+ self.config_dir = Path(config_dir)
53
+ else:
54
+ self.config_dir = get_profile_dir(profile)
42
55
  self._cached_key: Optional[bytes] = None
43
56
 
44
57
  def _get_key(self) -> bytes:
@@ -54,7 +67,7 @@ class TokenStore:
54
67
  Creates config dir (0o700), salt file (0o600) if needed,
55
68
  writes to .tmp then renames for atomicity.
56
69
  """
57
- self.config_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
70
+ ensure_profile_dir(self.config_dir)
58
71
 
59
72
  key = self._get_key()
60
73
 
src/cli/auth.py CHANGED
@@ -114,7 +114,7 @@ def _login_browser(ctx, port):
114
114
  from src.auth.token_store import TokenStore
115
115
  from src.config import CLIConfig
116
116
 
117
- config = CLIConfig()
117
+ config = CLIConfig(profile=ctx.profile)
118
118
  server_url = config.resolve_server_url(ctx.server_url)
119
119
  if not server_url:
120
120
  click.echo(
@@ -125,7 +125,7 @@ def _login_browser(ctx, port):
125
125
 
126
126
  verify_ssl = config.resolve_verify_ssl()
127
127
  flow = OAuthFlow(server_url, verify_ssl=verify_ssl)
128
- store = TokenStore()
128
+ store = TokenStore(profile=ctx.profile)
129
129
 
130
130
  try:
131
131
  if not ctx.json_mode:
@@ -152,7 +152,7 @@ def _login_device(ctx):
152
152
  from src.auth.oauth import OAuthFlow
153
153
  from src.config import CLIConfig
154
154
 
155
- config = CLIConfig()
155
+ config = CLIConfig(profile=ctx.profile)
156
156
  server_url = config.resolve_server_url(ctx.server_url)
157
157
  if not server_url:
158
158
  click.echo(
@@ -174,7 +174,7 @@ def _login_device(ctx):
174
174
  sys.exit(1)
175
175
 
176
176
  # Save pending device code for --check
177
- _save_pending_device(pending)
177
+ _save_pending_device(pending, ctx.profile)
178
178
 
179
179
  if ctx.json_mode:
180
180
  click.echo(json.dumps({
@@ -203,7 +203,7 @@ def _login_check(ctx):
203
203
  from src.auth.token_store import TokenStore
204
204
  from src.config import CLIConfig
205
205
 
206
- pending = _load_pending_device()
206
+ pending = _load_pending_device(ctx.profile)
207
207
  if not pending:
208
208
  msg = "No pending device authorization. Run: upscaler login --no-browser"
209
209
  if ctx.json_mode:
@@ -214,7 +214,7 @@ def _login_check(ctx):
214
214
 
215
215
  # Check expiry
216
216
  if time.time() >= pending["expires_at"]:
217
- _delete_pending_device()
217
+ _delete_pending_device(ctx.profile)
218
218
  msg = "Device code expired. Run: upscaler login --no-browser"
219
219
  if ctx.json_mode:
220
220
  click.echo(json.dumps({"error": msg, "expired": True}), err=True)
@@ -222,7 +222,7 @@ def _login_check(ctx):
222
222
  click.echo(msg, err=True)
223
223
  sys.exit(2)
224
224
 
225
- config = CLIConfig()
225
+ config = CLIConfig(profile=ctx.profile)
226
226
  server_url = pending.get("server_url") or config.resolve_server_url(ctx.server_url)
227
227
  verify_ssl = config.resolve_verify_ssl()
228
228
  flow = OAuthFlow(server_url, verify_ssl=verify_ssl)
@@ -245,7 +245,7 @@ def _login_check(ctx):
245
245
  )
246
246
  sys.exit(1)
247
247
  except RuntimeError as e:
248
- _delete_pending_device()
248
+ _delete_pending_device(ctx.profile)
249
249
  if ctx.json_mode:
250
250
  click.echo(json.dumps({"error": str(e)}), err=True)
251
251
  else:
@@ -253,9 +253,9 @@ def _login_check(ctx):
253
253
  sys.exit(2)
254
254
 
255
255
  # Success — save tokens and clean up pending file
256
- store = TokenStore()
256
+ store = TokenStore(profile=ctx.profile)
257
257
  store.save(token_data)
258
- _delete_pending_device()
258
+ _delete_pending_device(ctx.profile)
259
259
 
260
260
  if ctx.json_mode:
261
261
  expires_in = int(token_data.expires_at - time.time())
@@ -265,36 +265,41 @@ def _login_check(ctx):
265
265
 
266
266
 
267
267
  # ---------------------------------------------------------------------------
268
- # Pending device code persistence (~/.upscaler/pending_device.json)
268
+ # Pending device code persistence (~/.upscaler/profiles/{profile}/pending_device.json)
269
269
  # ---------------------------------------------------------------------------
270
270
 
271
271
  _PENDING_FILE = "pending_device.json"
272
272
 
273
273
 
274
- def _get_pending_path():
275
- """Get path to the pending device code file."""
274
+ def _get_pending_path(profile: str | None = None) -> str:
275
+ """Get path to the pending device code file for the active profile."""
276
276
  import os
277
277
 
278
- config_dir = os.path.expanduser("~/.upscaler")
279
- return os.path.join(config_dir, _PENDING_FILE)
278
+ from src.profile import get_profile_dir
280
279
 
280
+ config_dir = get_profile_dir(profile)
281
+ return os.path.join(str(config_dir), _PENDING_FILE)
281
282
 
282
- def _save_pending_device(data: dict) -> None:
283
+
284
+ def _save_pending_device(data: dict, profile: str | None = None) -> None:
283
285
  """Save pending device code to disk."""
284
286
  import os
287
+ from pathlib import Path
288
+
289
+ from src.profile import ensure_profile_dir
285
290
 
286
- path = _get_pending_path()
287
- os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
291
+ path = _get_pending_path(profile)
292
+ ensure_profile_dir(Path(os.path.dirname(path)))
288
293
  with open(path, "w") as f:
289
294
  f.write(json.dumps(data))
290
295
  os.chmod(path, 0o600)
291
296
 
292
297
 
293
- def _load_pending_device() -> dict | None:
298
+ def _load_pending_device(profile: str | None = None) -> dict | None:
294
299
  """Load pending device code from disk, or None if absent."""
295
300
  import os
296
301
 
297
- path = _get_pending_path()
302
+ path = _get_pending_path(profile)
298
303
  if not os.path.exists(path):
299
304
  return None
300
305
  try:
@@ -304,11 +309,11 @@ def _load_pending_device() -> dict | None:
304
309
  return None
305
310
 
306
311
 
307
- def _delete_pending_device() -> None:
312
+ def _delete_pending_device(profile: str | None = None) -> None:
308
313
  """Remove the pending device code file."""
309
314
  import os
310
315
 
311
- path = _get_pending_path()
316
+ path = _get_pending_path(profile)
312
317
  if os.path.exists(path):
313
318
  os.unlink(path)
314
319
 
@@ -326,9 +331,9 @@ def refresh(ctx):
326
331
  from src.auth.token_store import TokenStore
327
332
  from src.config import CLIConfig
328
333
 
329
- config = CLIConfig()
334
+ config = CLIConfig(profile=ctx.profile)
330
335
  server_url = config.resolve_server_url(ctx.server_url)
331
- store = TokenStore()
336
+ store = TokenStore(profile=ctx.profile)
332
337
 
333
338
  try:
334
339
  token_data = store.load()
@@ -370,7 +375,7 @@ def status(ctx):
370
375
  """
371
376
  from src.auth.token_store import TokenStore
372
377
 
373
- store = TokenStore()
378
+ store = TokenStore(profile=ctx.profile)
374
379
 
375
380
  try:
376
381
  token_data = store.load()
@@ -411,9 +416,9 @@ def logout(ctx):
411
416
  from src.auth.token_store import TokenStore
412
417
  from src.config import CLIConfig
413
418
 
414
- config = CLIConfig()
419
+ config = CLIConfig(profile=ctx.profile)
415
420
  server_url = config.resolve_server_url(ctx.server_url)
416
- store = TokenStore()
421
+ store = TokenStore(profile=ctx.profile)
417
422
 
418
423
  try:
419
424
  token_data = store.load()
src/cli/automation.py ADDED
@@ -0,0 +1,221 @@
1
+ """Automation management CLI commands.
2
+
3
+ Lists, inspects, and manages automations (scheduled actions on assets/members).
4
+ Wraps POST /api/v1/automations on the up-ai REST API.
5
+ """
6
+
7
+ import sys
8
+
9
+ import click
10
+
11
+ from src.cli.context import pass_context
12
+ from src.cli.helpers import execute_rest_action
13
+
14
+
15
+ @click.group("automation")
16
+ def automation_group():
17
+ """Manage automations: list, get, create, update, enable, disable, run, delete, runs.
18
+
19
+ Examples:
20
+ upscaler automation list
21
+ upscaler automation get auto_abc123
22
+ upscaler automation create --data @automation.json
23
+ upscaler automation enable auto_abc123
24
+ upscaler automation run auto_abc123
25
+ upscaler automation runs auto_abc123 --limit 50
26
+ upscaler automation list --asset-id d_xyz
27
+ """
28
+ pass
29
+
30
+
31
+ @automation_group.command("list")
32
+ @click.option("--search", default=None, help="Search filter.")
33
+ @click.option(
34
+ "--action-type",
35
+ "action_types",
36
+ multiple=True,
37
+ help=(
38
+ "Filter by action type (repeatable): createTodo, createRecord, "
39
+ "sendEmailDigest, createReviewRequest"
40
+ ),
41
+ )
42
+ @click.option(
43
+ "--asset-id",
44
+ default=None,
45
+ help="If given, list automations bound to this asset (uses getAssetAutomations).",
46
+ )
47
+ @click.option("--upcoming", is_flag=True, help="Show only upcoming automations.")
48
+ @click.option("--limit", default=50, type=int, help="Page size (default 50).")
49
+ @click.option("--offset", default=0, type=int, help="Page offset (default 0).")
50
+ @pass_context
51
+ def automation_list(ctx, search, action_types, asset_id, upcoming, limit, offset):
52
+ """List automations."""
53
+ if upcoming and asset_id:
54
+ click.echo("--upcoming and --asset-id are mutually exclusive", err=True)
55
+ sys.exit(1)
56
+
57
+ if asset_id:
58
+ action = "list_for_asset"
59
+ payload = {"action": action, "asset_id": asset_id, "limit": limit, "offset": offset}
60
+ elif upcoming:
61
+ action = "list_upcoming"
62
+ payload = {"action": action, "limit": limit, "offset": offset}
63
+ else:
64
+ action = "list"
65
+ payload = {"action": action, "limit": limit, "offset": offset}
66
+ if search:
67
+ payload["search"] = search
68
+ if action_types:
69
+ payload["action_types"] = list(action_types)
70
+
71
+ _execute(ctx, payload)
72
+
73
+
74
+ @automation_group.command("get")
75
+ @click.argument("automation_id")
76
+ @pass_context
77
+ def automation_get(ctx, automation_id):
78
+ """Fetch a single automation by ID."""
79
+ _execute(ctx, {"action": "get", "id": automation_id})
80
+
81
+
82
+ @automation_group.command("runs")
83
+ @click.argument("automation_id")
84
+ @click.option("--limit", default=20, type=int)
85
+ @click.option("--offset", default=0, type=int)
86
+ @pass_context
87
+ def automation_runs(ctx, automation_id, limit, offset):
88
+ """List recent runs of an automation."""
89
+ _execute(
90
+ ctx,
91
+ {"action": "list_runs", "id": automation_id, "limit": limit, "offset": offset},
92
+ )
93
+
94
+
95
+ @automation_group.command("create")
96
+ @click.option(
97
+ "--data",
98
+ "data_input",
99
+ required=True,
100
+ help="JSON payload (value, - for stdin, @file).",
101
+ )
102
+ @click.option("--dry-run", is_flag=True, help="Preview without creating.")
103
+ @pass_context
104
+ def automation_create(ctx, data_input, dry_run):
105
+ """Create an automation.
106
+
107
+ --data should be CreateAutomationInput shape, e.g.:
108
+ {"title": "Weekly review", "trigger": {"type": "schedule",
109
+ "schedule": {"cron": "0 9 * * 1", "timezone": "UTC"}},
110
+ "target": {"type": "asset", "asset": {"id": "d_xxx", "type": "document"}},
111
+ "action": {"type": "createTodo", "createTodo":
112
+ {"title": "Review doc", "assignees": ["user_abc"]}}}
113
+ """
114
+ from src.cli.helpers import parse_data
115
+
116
+ try:
117
+ data = parse_data(data_input)
118
+ except Exception as e:
119
+ click.echo(str(e), err=True)
120
+ sys.exit(1)
121
+
122
+ _execute(ctx, {"action": "create", "data": data}, dry_run=dry_run)
123
+
124
+
125
+ @automation_group.command("update")
126
+ @click.argument("automation_id")
127
+ @click.option("--data", "data_input", required=True, help="JSON payload.")
128
+ @click.option("--dry-run", is_flag=True)
129
+ @pass_context
130
+ def automation_update(ctx, automation_id, data_input, dry_run):
131
+ """Update an automation."""
132
+ from src.cli.helpers import parse_data
133
+
134
+ try:
135
+ data = parse_data(data_input)
136
+ except Exception as e:
137
+ click.echo(str(e), err=True)
138
+ sys.exit(1)
139
+
140
+ _execute(ctx, {"action": "update", "id": automation_id, "data": data}, dry_run=dry_run)
141
+
142
+
143
+ @automation_group.command("enable")
144
+ @click.argument("automation_id")
145
+ @pass_context
146
+ def automation_enable(ctx, automation_id):
147
+ """Enable an automation."""
148
+ _execute(ctx, {"action": "enable", "id": automation_id})
149
+
150
+
151
+ @automation_group.command("disable")
152
+ @click.argument("automation_id")
153
+ @pass_context
154
+ def automation_disable(ctx, automation_id):
155
+ """Disable an automation."""
156
+ _execute(ctx, {"action": "disable", "id": automation_id})
157
+
158
+
159
+ @automation_group.command("run")
160
+ @click.argument("automation_id")
161
+ @pass_context
162
+ def automation_run(ctx, automation_id):
163
+ """Manually trigger an automation now."""
164
+ _execute(ctx, {"action": "run", "id": automation_id})
165
+
166
+
167
+ @automation_group.command("delete")
168
+ @click.argument("automation_id")
169
+ @click.option("--dry-run", is_flag=True)
170
+ @pass_context
171
+ def automation_delete(ctx, automation_id, dry_run):
172
+ """Delete an automation."""
173
+ from src.cli.helpers import confirm_destructive
174
+
175
+ if not dry_run and not confirm_destructive("delete", automation_id, ctx.json_mode):
176
+ click.echo("Cancelled.", err=True)
177
+ return
178
+
179
+ _execute(ctx, {"action": "delete", "id": automation_id}, dry_run=dry_run)
180
+
181
+
182
+ def _execute(ctx, payload, dry_run=False):
183
+ execute_rest_action(
184
+ ctx, payload, "/api/v1/automations", render=_render_human, dry_run=dry_run
185
+ )
186
+
187
+
188
+ def _render_human(action, result):
189
+ from src.formatters.json_fmt import format_json
190
+
191
+ if not result.get("success"):
192
+ click.echo(format_json(result))
193
+ return
194
+
195
+ data = result.get("data")
196
+ meta = result.get("metadata") or {}
197
+
198
+ if isinstance(data, list):
199
+ if not data:
200
+ click.echo("(no results)")
201
+ return
202
+ for item in data:
203
+ if isinstance(item, dict):
204
+ if "triggeredAt" in item or "scheduleId" in item:
205
+ click.echo(
206
+ f"{item.get('triggeredAt', '')} {item.get('status', ''):8} "
207
+ f"{item.get('resultTitle', '') or item.get('id', '')}"
208
+ )
209
+ else:
210
+ enabled = "on " if item.get("enabled") else "off"
211
+ next_due = item.get("nextDue") or ""
212
+ click.echo(
213
+ f"{item.get('id', ''):28} [{enabled}] "
214
+ f"{item.get('title', '') or '(untitled)':40} next: {next_due}"
215
+ )
216
+ if meta.get("total_count") is not None:
217
+ click.echo(f"\nTotal: {meta['total_count']} (showing {len(data)})")
218
+ elif isinstance(data, dict):
219
+ click.echo(format_json(data))
220
+ else:
221
+ click.echo(str(data))
src/cli/config_cmd.py CHANGED
@@ -1,11 +1,15 @@
1
- """CLI config command for persistent settings."""
1
+ """CLI config command for persistent settings (per profile)."""
2
2
 
3
3
  import click
4
4
 
5
+ from src.cli.context import pass_context
5
6
  from src.config import VALID_KEYS, CLIConfig
6
7
 
7
8
 
8
- @click.group("config", help="Manage persistent CLI settings in ~/.upscaler/config.json.")
9
+ @click.group(
10
+ "config",
11
+ help="Manage persistent CLI settings in ~/.upscaler/profiles/{profile}/config.json.",
12
+ )
9
13
  def config_group():
10
14
  pass
11
15
 
@@ -13,12 +17,13 @@ def config_group():
13
17
  @config_group.command("set")
14
18
  @click.argument("key")
15
19
  @click.argument("value")
16
- def config_set(key, value):
17
- """Set a config value. Valid keys: server_url, oauth_url, default_format."""
20
+ @pass_context
21
+ def config_set(ctx, key, value):
22
+ """Set a config value on the active profile."""
18
23
  try:
19
- config = CLIConfig()
24
+ config = CLIConfig(profile=ctx.profile)
20
25
  config.set(key, value)
21
- click.echo(f"{key} = {value}")
26
+ click.echo(f"[{ctx.profile}] {key} = {value}")
22
27
  except ValueError as e:
23
28
  click.echo(str(e), err=True)
24
29
  raise SystemExit(1)
@@ -26,14 +31,15 @@ def config_set(key, value):
26
31
 
27
32
  @config_group.command("get")
28
33
  @click.argument("key")
29
- def config_get(key):
30
- """Get a config value."""
34
+ @pass_context
35
+ def config_get(ctx, key):
36
+ """Get a config value from the active profile."""
31
37
  if key not in VALID_KEYS:
32
38
  click.echo(
33
39
  f"Unknown config key: {key}. Valid keys: {', '.join(sorted(VALID_KEYS))}",
34
40
  err=True,
35
41
  )
36
42
  raise SystemExit(1)
37
- config = CLIConfig()
43
+ config = CLIConfig(profile=ctx.profile)
38
44
  value = config.get(key)
39
45
  click.echo(value if value else "")
src/cli/context.py CHANGED
@@ -4,6 +4,8 @@ from typing import Optional
4
4
 
5
5
  import click
6
6
 
7
+ from src.profile import DEFAULT_PROFILE
8
+
7
9
 
8
10
  class Context:
9
11
  """Shared context object passed to all commands."""
@@ -12,6 +14,7 @@ class Context:
12
14
  self.json_mode: bool = False
13
15
  self.verbose: bool = False
14
16
  self.server_url: Optional[str] = None
17
+ self.profile: str = DEFAULT_PROFILE
15
18
 
16
19
 
17
20
  pass_context = click.make_pass_decorator(Context, ensure=True)