Skip to content

Commit 125a3e2

Browse files
Douglas Blankclaude
andcommitted
Fix review issues in migrate-users PR
- copy.py: move `import requests` to top-level; use `self.api._get_url_server()` instead of broken `self.api.server_url`; split HTTPError vs RequestException handlers for precise error messages - migrate_users.py: add --url/--source-url args for self-hosted instances; fix _resolve_server_url (base64 padding, error handling, explicit-URL priority); require --source-api-key explicitly instead of silently reusing COMET_API_KEY; warn when source and dest URL+key are identical; handle dict-list API response in _get_existing_workspaces; show per-user output in dry-run; add --failures-output flag; use json= kwarg in _add_member; fix docstring examples - tests/unit/test_migrate_users.py: 17 new unit tests covering _resolve_server_url, _get_existing_workspaces, _add_member, and dry-run behaviour Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7cd5e4b commit 125a3e2

File tree

5 files changed

+301
-39
lines changed

5 files changed

+301
-39
lines changed

README.md

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -333,24 +333,25 @@ For more information, `cometx log --help`
333333

334334
## cometx migrate-users
335335

336-
This command is used to migrate users into workspaces from a source Comet environment to a destination environment. It reads workspace membership from a chargeback report from the source environment and invites each user to the corresponding workspace in the destination environment by email. If a user with the email does not exist in the destination environment, they still will be provisioned access to these workspaces once they sign up using that email.
337-
338-
The chargeback report can be fetched automatically from the source environment's admin API, or you can provide a pre-downloaded JSON file.
336+
This command migrates users into workspaces from a source Comet environment to a destination environment. It reads workspace membership from a chargeback report fetched from the source environment's admin API (or a pre-downloaded local file) and invites each user to the corresponding workspace in the destination environment by email. If a user with that email does not yet exist in the destination, they will be provisioned access once they sign up.
339337

340338
```
341339
cometx migrate-users --api-key DEST_KEY --source-api-key SOURCE_KEY [FLAGS ...]
342340
cometx migrate-users --api-key DEST_KEY --chargeback-report /path/to/report.json [FLAGS ...]
343341
```
344342

345343
**Arguments:**
346-
* `--api-key API_KEY` - API key for the destination environment where users will be added. Falls back to `COMET_API_KEY` environment variable if not provided.
347-
* `--source-api-key SOURCE_API_KEY` - API key for the source environment. Used to fetch the chargeback report. Falls back to `COMET_API_KEY` environment variable if not provided. Required unless `--chargeback-report` is given.
348-
* `--chargeback-report PATH` - Path to a local chargeback report JSON file. When provided, the report is loaded from this file instead of being fetched from the source environment, and `--source-api-key` is not required.
344+
* `--api-key API_KEY` - API key for the destination environment. Falls back to `COMET_API_KEY` if not provided.
345+
* `--url URL` - Base URL of the destination Comet environment (e.g. `https://comet.example.com`). Required for self-hosted instances when the API key does not encode the server URL.
346+
* `--source-api-key SOURCE_API_KEY` - API key for the source environment. Used to fetch the chargeback report. Required unless `--chargeback-report` is given.
347+
* `--source-url SOURCE_URL` - Base URL of the source Comet environment. Required for self-hosted source instances when the source API key does not encode the server URL.
348+
* `--chargeback-report PATH` - Path to a local chargeback report JSON file. When provided, `--source-api-key` is not required.
349349

350350
### Flags
351351

352-
* `--create-workspaces` - Create workspaces on the destination environment if they don't already exist (default: off)
352+
* `--create-workspaces` - Create workspaces on the destination if they don't already exist (default: off)
353353
* `--dry-run` - Print what would happen without making any changes
354+
* `--failures-output PATH` - Path to write failed operations JSON (default: `bulk_add_failures_by_email.json`)
354355

355356
### Examples
356357

@@ -364,6 +365,9 @@ cometx migrate-users --api-key DEST_KEY --source-api-key SOURCE_KEY
364365
# Use a local chargeback report file
365366
cometx migrate-users --api-key DEST_KEY --chargeback-report /tmp/chargeback_reports.json
366367

368+
# Self-hosted environments — provide explicit URLs
369+
cometx migrate-users --api-key DEST_KEY --url https://comet.dest.example.com \
370+
--source-api-key SOURCE_KEY --source-url https://comet.src.example.com
367371
```
368372

369373
For more information, `cometx migrate-users --help`
@@ -518,12 +522,8 @@ cometx admin chargeback-report [YEAR-MONTH]
518522
**Examples:**
519523
```
520524
cometx admin chargeback-report
521-
<<<<<<< Updated upstream
522-
cometx admin usage-report
523-
=======
524525
cometx admin chargeback-report 2024-09
525526
```
526-
>>>>>>> Stashed changes
527527

528528
#### usage-report
529529

cometx/cli/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ def main(raw_args=sys.argv[1:]):
108108
add_subparser(subparsers, count, "count")
109109
add_subparser(subparsers, reproduce, "reproduce")
110110
add_subparser(subparsers, config, "config")
111+
add_subparser(subparsers, migrate_users, "migrate-users")
111112
add_subparser(subparsers, rename_duplicates, "rename-duplicates")
112113
add_subparser(subparsers, smoke_test, "smoke-test")
113114
add_subparser(subparsers, migrate_users, "migrate-users")

cometx/cli/copy.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@
7878
import time
7979
import urllib.parse
8080
import zipfile
81+
82+
import requests
8183
from datetime import datetime, timedelta
8284

8385
from comet_ml import APIExperiment, Artifact, Experiment, OfflineExperiment
@@ -834,10 +836,8 @@ def copy(self, source, destination, symlink, ignore, debug, sync, create_workspa
834836
print(
835837
f"Workspace {workspace_dst!r} does not exist, attempting to create it..."
836838
)
839+
url = f"{self.api._get_url_server()}/api/rest/v2/write/workspace/new"
837840
try:
838-
import requests
839-
840-
url = f"{self.api.server_url}/api/rest/v2/write/workspace/new"
841841
response = requests.post(
842842
url,
843843
json={"name": workspace_dst},
@@ -848,12 +848,18 @@ def copy(self, source, destination, symlink, ignore, debug, sync, create_workspa
848848
)
849849
response.raise_for_status()
850850
print(f"Workspace {workspace_dst!r} created successfully.")
851-
except Exception as exc:
851+
except requests.exceptions.HTTPError as exc:
852+
raise Exception(
853+
f"Workspace {workspace_dst!r} does not exist and could not be "
854+
f"created automatically (HTTP {exc.response.status_code}). "
855+
f"Please create it via the Comet UI and try again."
856+
) from exc
857+
except requests.exceptions.RequestException as exc:
852858
raise Exception(
853859
f"Workspace {workspace_dst!r} does not exist and could not be "
854860
f"created automatically: {exc}. "
855861
f"Please create it via the Comet UI and try again."
856-
)
862+
) from exc
857863
else:
858864
raise Exception(
859865
f"{workspace_dst} does not exist; use --create-workspaces to "

cometx/cli/migrate_users.py

Lines changed: 73 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,17 @@
2020
2121
Examples:
2222
23-
$ cometx --api-key DEST_KEY migrate-users --source-api-key SOURCE_KEY --dry-run
24-
$ cometx --api-key DEST_KEY migrate-users --source-api-key SOURCE_KEY
25-
$ cometx --api-key DEST_KEY migrate-users --chargeback-report /path/to/report.json
23+
$ cometx migrate-users --api-key DEST_KEY --source-api-key SOURCE_KEY --dry-run
24+
$ cometx migrate-users --api-key DEST_KEY --source-api-key SOURCE_KEY
25+
$ cometx migrate-users --api-key DEST_KEY --chargeback-report /path/to/report.json
2626
"""
2727

2828
import argparse
2929
import base64
3030
import json
3131
import os
3232
import sys
33+
3334
import requests
3435

3536
ADDITIONAL_ARGS = False
@@ -40,13 +41,29 @@
4041
def get_parser_arguments(parser):
4142
parser.add_argument(
4243
"--api-key",
43-
help="API key for the destination environment (used to add workspace members)",
44+
help="API key for the destination environment (used to add workspace members). "
45+
"Falls back to COMET_API_KEY environment variable if not provided.",
46+
type=str,
47+
default=None,
48+
)
49+
parser.add_argument(
50+
"--url",
51+
help="Base URL of the destination Comet environment (e.g. https://comet.example.com). "
52+
"Required for self-hosted instances when the API key does not encode the server URL.",
4453
type=str,
4554
default=None,
4655
)
4756
parser.add_argument(
4857
"--source-api-key",
49-
help="API key for the source environment (used to fetch the chargeback report)",
58+
help="API key for the source environment (used to fetch the chargeback report). "
59+
"Required unless --chargeback-report is given.",
60+
type=str,
61+
default=None,
62+
)
63+
parser.add_argument(
64+
"--source-url",
65+
help="Base URL of the source Comet environment. "
66+
"Required for self-hosted instances when the source API key does not encode the server URL.",
5067
type=str,
5168
default=None,
5269
)
@@ -68,15 +85,36 @@ def get_parser_arguments(parser):
6885
default=False,
6986
action="store_true",
7087
)
88+
parser.add_argument(
89+
"--failures-output",
90+
help="Path to write failed operations JSON (default: bulk_add_failures_by_email.json)",
91+
type=str,
92+
default="bulk_add_failures_by_email.json",
93+
)
7194

7295

73-
def _resolve_server_url(api_key):
74-
if "*" not in api_key:
75-
return COMET_CLOUD_URL
96+
def _resolve_server_url(api_key, explicit_url=None):
97+
"""Return the server base URL.
7698
77-
_, encoded = api_key.split("*", 1)
78-
payload = json.loads(base64.b64decode(encoded))
79-
return payload["baseUrl"].rstrip("/")
99+
Priority:
100+
1. ``explicit_url`` (from --url / --source-url), if provided.
101+
2. URL encoded inside the API key (new-style keys contain ``*<base64>``).
102+
3. COMET_CLOUD_URL as a last resort.
103+
"""
104+
if explicit_url:
105+
return explicit_url.rstrip("/")
106+
107+
if "*" in api_key:
108+
try:
109+
_, encoded = api_key.split("*", 1)
110+
# base64 padding may be missing
111+
padding = (4 - len(encoded) % 4) % 4
112+
payload = json.loads(base64.b64decode(encoded + "=" * padding))
113+
return payload["baseUrl"].rstrip("/")
114+
except Exception:
115+
pass # Fall through to default
116+
117+
return COMET_CLOUD_URL
80118

81119

82120
def _fetch_chargeback_report(server_url, source_api_key):
@@ -101,7 +139,11 @@ def _get_existing_workspaces(dest_url, headers):
101139
url = f"{dest_url}/api/rest/v2/workspaces"
102140
resp = requests.get(url, headers=headers, timeout=15)
103141
resp.raise_for_status()
104-
return set(resp.json())
142+
data = resp.json()
143+
# The endpoint may return a list of strings or a list of dicts with a "name" key.
144+
if data and isinstance(data[0], dict):
145+
return {ws["name"] for ws in data}
146+
return set(data)
105147

106148

107149
def _create_workspace(dest_url, headers, workspace_name):
@@ -126,7 +168,7 @@ def _add_member(url, headers, email, workspace_name):
126168
response = requests.post(
127169
url,
128170
headers=headers,
129-
data=json.dumps(payload),
171+
json=payload,
130172
timeout=15,
131173
)
132174

@@ -163,22 +205,30 @@ def migrate_users(parsed_args):
163205
print("[ERROR] No API key found. Set COMET_API_KEY or pass --api-key.")
164206
sys.exit(1)
165207

166-
source_api_key = parsed_args.source_api_key or os.environ.get("COMET_API_KEY")
208+
source_api_key = parsed_args.source_api_key
167209
create_workspaces = parsed_args.create_workspaces
168210
dry_run = parsed_args.dry_run
211+
failures_output = parsed_args.failures_output
169212

170213
if not parsed_args.chargeback_report and not source_api_key:
171-
print("[ERROR] Provide either --source-api-key or --chargeback-report.")
214+
print(
215+
"[ERROR] --source-api-key is required when --chargeback-report is not provided."
216+
)
172217
sys.exit(1)
173218

174-
dest_url = _resolve_server_url(api_key)
219+
dest_url = _resolve_server_url(api_key, parsed_args.url)
175220
print(f"Destination URL: {dest_url}")
176221

177222
if parsed_args.chargeback_report:
178223
data = _load_chargeback_report(parsed_args.chargeback_report)
179224
else:
180-
source_url = _resolve_server_url(source_api_key)
225+
source_url = _resolve_server_url(source_api_key, parsed_args.source_url)
181226
print(f"Source URL: {source_url}")
227+
if source_url == dest_url and source_api_key == api_key:
228+
print(
229+
"[WARNING] Source and destination URL and API key are identical. "
230+
"Are you sure you want to migrate users to the same environment?"
231+
)
182232
try:
183233
data = _fetch_chargeback_report(source_url, source_api_key)
184234
except requests.exceptions.RequestException as e:
@@ -215,7 +265,7 @@ def migrate_users(parsed_args):
215265
elif create_workspaces and dry_run:
216266
print("[DRY RUN] Would check/create workspaces on destination\n")
217267

218-
url = f"{dest_url}/api/rest/v2/write/add-workspace-member"
268+
add_member_url = f"{dest_url}/api/rest/v2/write/add-workspace-member"
219269

220270
total_added = 0
221271
total_skipped = 0
@@ -252,11 +302,12 @@ def migrate_users(parsed_args):
252302
continue
253303

254304
if dry_run:
305+
print(f" [DRY RUN] Would add '{email}' to '{ws_name}'")
255306
ws_added += 1
256307
total_added += 1
257308
continue
258309

259-
status, error_info = _add_member(url, headers, email, ws_name)
310+
status, error_info = _add_member(add_member_url, headers, email, ws_name)
260311

261312
if status == "added":
262313
ws_added += 1
@@ -280,7 +331,7 @@ def migrate_users(parsed_args):
280331
)
281332

282333
if dry_run:
283-
print(f" [DRY RUN] Would add {ws_added}/{len(members)} users")
334+
print(f" [DRY RUN] Total: would add {ws_added}/{len(members)} users")
284335
else:
285336
parts = [f" Added {ws_added}/{len(members)} users successfully"]
286337
if ws_already_member:
@@ -309,10 +360,9 @@ def migrate_users(parsed_args):
309360
print(f" *** Remove --dry-run to execute for real ***")
310361

311362
if failures:
312-
failures_file = "bulk_add_failures_by_email.json"
313-
with open(failures_file, "w") as f:
363+
with open(failures_output, "w") as f:
314364
json.dump(failures, f, indent=2)
315-
print(f"\n Failed operations saved to {failures_file}")
365+
print(f"\n Failed operations saved to {failures_output}")
316366

317367

318368
def main(args):

0 commit comments

Comments
 (0)