dayhoff-tools 1.0.0__py3-none-any.whl → 1.0.2__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.
@@ -0,0 +1,597 @@
1
+ """CLI commands for cloud provider authentication and management.
2
+
3
+ This module provides commands for authenticating with GCP and AWS from within
4
+ development containers. It handles both immediate shell environment configuration
5
+ via the --export flag and persistent configuration via shell RC files.
6
+
7
+ The implementation focuses on:
8
+ 1. Unifying cloud authentication with the `dh` CLI tool
9
+ 2. Maintaining persistence across shell sessions via RC file modifications
10
+ 3. Providing similar capabilities to the shell scripts it replaces
11
+ """
12
+
13
+ import os
14
+ import re
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ from pathlib import Path
19
+ from typing import List, Optional, Tuple
20
+
21
+ import questionary
22
+ import typer
23
+
24
+ # --- Configuration ---
25
+ GCP_DEVCON_SA = "devcon@enzyme-discovery.iam.gserviceaccount.com"
26
+ GCP_PROJECT_ID = "enzyme-discovery"
27
+ AWS_DEFAULT_PROFILE = "dev-devaccess"
28
+ AWS_CONFIG_FILE = Path.home() / ".aws" / "config"
29
+ SHELL_RC_FILES = [
30
+ Path.home() / ".bashrc",
31
+ Path.home() / ".bash_profile",
32
+ Path.home() / ".profile",
33
+ ]
34
+
35
+ # --- Color constants for formatted output ---
36
+ RED = "\033[0;31m"
37
+ GREEN = "\033[0;32m"
38
+ YELLOW = "\033[0;33m"
39
+ BLUE = "\033[0;36m"
40
+ NC = "\033[0m" # No Color
41
+
42
+
43
+ # --- Common Helper Functions ---
44
+ def _find_executable(name: str) -> str:
45
+ """Find the full path to an executable in PATH."""
46
+ path = shutil.which(name)
47
+ if not path:
48
+ raise FileNotFoundError(
49
+ f"{name} command not found. Please ensure it's installed."
50
+ )
51
+ return path
52
+
53
+
54
+ def _run_command(
55
+ cmd_list: List[str],
56
+ capture: bool = False,
57
+ check: bool = True,
58
+ suppress_output: bool = False,
59
+ ) -> Tuple[int, str, str]:
60
+ """Run a command and return its result.
61
+
62
+ Args:
63
+ cmd_list: List of command arguments
64
+ capture: Whether to capture output
65
+ check: Whether to raise on non-zero exit code
66
+ suppress_output: Whether to hide output even if not captured
67
+
68
+ Returns:
69
+ Tuple of (return_code, stdout_str, stderr_str)
70
+ """
71
+ stdout_opt = (
72
+ subprocess.PIPE if capture else subprocess.DEVNULL if suppress_output else None
73
+ )
74
+ stderr_opt = (
75
+ subprocess.PIPE if capture else subprocess.DEVNULL if suppress_output else None
76
+ )
77
+
78
+ try:
79
+ result = subprocess.run(
80
+ cmd_list, stdout=stdout_opt, stderr=stderr_opt, check=check, text=True
81
+ )
82
+ return (
83
+ result.returncode,
84
+ result.stdout if capture else "",
85
+ result.stderr if capture else "",
86
+ )
87
+ except subprocess.CalledProcessError as e:
88
+ if capture:
89
+ return (e.returncode, e.stdout or "", e.stderr or "")
90
+ return (e.returncode, "", "")
91
+
92
+
93
+ def _modify_rc_files(variable: str, value: Optional[str]) -> None:
94
+ """Add or remove an export line from RC files.
95
+
96
+ Args:
97
+ variable: Environment variable name
98
+ value: Value to set, or None to remove
99
+ """
100
+ for rc_file in SHELL_RC_FILES:
101
+ if not rc_file.exists():
102
+ continue
103
+
104
+ try:
105
+ # Read existing content
106
+ with open(rc_file, "r") as f:
107
+ lines = f.readlines()
108
+
109
+ # Filter out existing exports for this variable
110
+ pattern = re.compile(f"^export {variable}=")
111
+ new_lines = [line for line in lines if not pattern.match(line.strip())]
112
+
113
+ # Add new export if value is provided
114
+ if value is not None:
115
+ new_lines.append(f"export {variable}={value}\n")
116
+
117
+ # Write back to file
118
+ with open(rc_file, "w") as f:
119
+ f.writelines(new_lines)
120
+
121
+ except (IOError, PermissionError) as e:
122
+ print(f"Warning: Could not update {rc_file}: {e}", file=sys.stderr)
123
+
124
+
125
+ def _get_env_var(variable: str) -> Optional[str]:
126
+ """Safely get an environment variable."""
127
+ return os.environ.get(variable)
128
+
129
+
130
+ # --- GCP Functions ---
131
+ def _is_gcp_user_authenticated() -> bool:
132
+ """Check if a user is authenticated with GCP (not a compute service account)."""
133
+ gcloud_path = _find_executable("gcloud")
134
+ cmd = [
135
+ gcloud_path,
136
+ "auth",
137
+ "list",
138
+ "--filter=status:ACTIVE",
139
+ "--format=value(account)",
140
+ ]
141
+ _, stdout, _ = _run_command(cmd, capture=True, check=False)
142
+
143
+ account = stdout.strip()
144
+ return bool(account) and "compute@developer.gserviceaccount.com" not in account
145
+
146
+
147
+ def _get_current_gcp_user() -> str:
148
+ """Get the currently authenticated GCP user."""
149
+ gcloud_path = _find_executable("gcloud")
150
+ cmd = [
151
+ gcloud_path,
152
+ "auth",
153
+ "list",
154
+ "--filter=status:ACTIVE",
155
+ "--format=value(account)",
156
+ ]
157
+ _, stdout, _ = _run_command(cmd, capture=True, check=False)
158
+
159
+ account = stdout.strip()
160
+ if account:
161
+ if "compute@developer.gserviceaccount.com" in account:
162
+ return "Not authenticated (using VM service account)"
163
+ return account
164
+ return "Not authenticated"
165
+
166
+
167
+ def _get_current_gcp_impersonation() -> str:
168
+ """Get the current impersonated service account, if any."""
169
+ sa = _get_env_var("CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT")
170
+ return sa if sa else "None"
171
+
172
+
173
+ def _run_gcloud_login() -> None:
174
+ """Run the gcloud auth login command."""
175
+ gcloud_path = _find_executable("gcloud")
176
+ print(f"{BLUE}Authenticating with Google Cloud...{NC}")
177
+ _run_command([gcloud_path, "auth", "login"])
178
+ print(f"{GREEN}Authentication complete.{NC}")
179
+
180
+
181
+ def _test_gcp_credentials(user: str, impersonation_sa: str) -> None:
182
+ """Test GCP credentials with and without impersonation."""
183
+ gcloud_path = _find_executable("gcloud")
184
+
185
+ print(f"\n{BLUE}Testing credentials...{NC}")
186
+
187
+ if user != "Not authenticated" and "Not authenticated" not in user:
188
+ if impersonation_sa != "None":
189
+ # Test user account first by temporarily unsetting impersonation
190
+ orig_impersonation = _get_env_var(
191
+ "CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT"
192
+ )
193
+ if orig_impersonation:
194
+ del os.environ["CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT"]
195
+
196
+ print(f"First with user account {user}:")
197
+ cmd = [gcloud_path, "compute", "zones", "list", "--limit=1"]
198
+ returncode, _, _ = _run_command(cmd, suppress_output=True, check=False)
199
+
200
+ if returncode == 0:
201
+ print(f"{GREEN}✓ User has direct GCP access{NC}")
202
+ else:
203
+ print(f"{YELLOW}✗ User lacks direct GCP access{NC}")
204
+
205
+ # Restore impersonation and test with it
206
+ if orig_impersonation:
207
+ os.environ["CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT"] = (
208
+ orig_impersonation
209
+ )
210
+
211
+ print(f"Then impersonating {impersonation_sa}:")
212
+ returncode, _, _ = _run_command(cmd, suppress_output=True, check=False)
213
+
214
+ if returncode == 0:
215
+ print(f"{GREEN}✓ Successfully using devcon service account{NC}")
216
+ else:
217
+ print(
218
+ f"{RED}Failed to access GCP resources with impersonation. Check permissions.{NC}"
219
+ )
220
+ else:
221
+ # Test user account directly (no impersonation)
222
+ print(f"Using user account {user} (no impersonation):")
223
+ cmd = [gcloud_path, "compute", "zones", "list", "--limit=1"]
224
+ returncode, _, _ = _run_command(cmd, suppress_output=True, check=False)
225
+
226
+ if returncode == 0:
227
+ print(f"{GREEN}✓ Successfully using personal account{NC}")
228
+ else:
229
+ print(f"{RED}Failed to access GCP resources. Check permissions.{NC}")
230
+
231
+
232
+ # --- AWS Functions ---
233
+ def _unset_aws_static_creds() -> None:
234
+ """Unset static AWS credential environment variables."""
235
+ _modify_rc_files("AWS_ACCESS_KEY_ID", None)
236
+ _modify_rc_files("AWS_SECRET_ACCESS_KEY", None)
237
+ _modify_rc_files("AWS_SESSION_TOKEN", None)
238
+
239
+
240
+ def _set_aws_profile(profile: str) -> None:
241
+ """Set and persist AWS profile in environment and RC files."""
242
+ _modify_rc_files("AWS_PROFILE", profile)
243
+ _unset_aws_static_creds()
244
+
245
+
246
+ def _get_current_aws_profile() -> str:
247
+ """Get the current AWS profile."""
248
+ # Check environment variable first
249
+ profile = _get_env_var("AWS_PROFILE")
250
+ if profile:
251
+ return profile
252
+
253
+ # Try using aws command to check
254
+ aws_path = _find_executable("aws")
255
+ try:
256
+ cmd = [aws_path, "configure", "list", "--no-cli-pager"]
257
+ _, stdout, _ = _run_command(cmd, capture=True, check=False)
258
+
259
+ # Extract profile from output
260
+ profile_match = re.search(r"profile\s+(\S+)", stdout)
261
+ if profile_match and profile_match.group(1) not in ("<not", "not"):
262
+ return profile_match.group(1)
263
+ except:
264
+ pass
265
+
266
+ # Default if nothing else works
267
+ return AWS_DEFAULT_PROFILE
268
+
269
+
270
+ def _is_aws_profile_authenticated(profile: str) -> bool:
271
+ """Check if an AWS profile has valid credentials."""
272
+ aws_path = _find_executable("aws")
273
+ cmd = [
274
+ aws_path,
275
+ "sts",
276
+ "get-caller-identity",
277
+ "--profile",
278
+ profile,
279
+ "--no-cli-pager",
280
+ ]
281
+ returncode, _, _ = _run_command(cmd, suppress_output=True, check=False)
282
+ return returncode == 0
283
+
284
+
285
+ def _run_aws_sso_login(profile: str) -> None:
286
+ """Run the AWS SSO login command for a specific profile."""
287
+ aws_path = _find_executable("aws")
288
+ print(f"{BLUE}Running 'aws sso login --profile {profile}'...{NC}")
289
+ _run_command([aws_path, "sso", "login", "--profile", profile])
290
+ print(f"{GREEN}Authentication complete.{NC}")
291
+
292
+
293
+ def _get_available_aws_profiles() -> List[str]:
294
+ """Get list of available AWS profiles from config file."""
295
+ profiles = []
296
+
297
+ if not AWS_CONFIG_FILE.exists():
298
+ return profiles
299
+
300
+ try:
301
+ with open(AWS_CONFIG_FILE, "r") as f:
302
+ lines = f.readlines()
303
+
304
+ for line in lines:
305
+ # Match [profile name] or [name] if default profile
306
+ match = re.match(r"^\[(?:profile\s+)?([^\]]+)\]", line.strip())
307
+ if match:
308
+ profiles.append(match.group(1))
309
+ except:
310
+ pass
311
+
312
+ return profiles
313
+
314
+
315
+ # --- Typer Applications ---
316
+ gcp_app = typer.Typer(help="Manage GCP authentication and impersonation.")
317
+ aws_app = typer.Typer(help="Manage AWS SSO authentication.")
318
+
319
+
320
+ # --- GCP Commands ---
321
+ @gcp_app.command("status")
322
+ def gcp_status():
323
+ """Show current GCP authentication and impersonation status."""
324
+ user_account = _get_current_gcp_user()
325
+ impersonated_sa = _get_current_gcp_impersonation()
326
+
327
+ print(f"{BLUE}GCP Status:{NC}")
328
+ print(f"User account: {GREEN}{user_account}{NC}")
329
+ print(f"Service account: {GREEN}{impersonated_sa}{NC}")
330
+ print(f"Project: {GREEN}{GCP_PROJECT_ID}{NC}")
331
+ print(
332
+ f"Mode: {GREEN}{'Service account impersonation' if impersonated_sa != 'None' else 'Personal account'}{NC}"
333
+ )
334
+
335
+ _test_gcp_credentials(user_account, impersonated_sa)
336
+
337
+
338
+ @gcp_app.command("login")
339
+ def gcp_login():
340
+ """Authenticate with GCP using your Google account."""
341
+ _run_gcloud_login()
342
+ print("\nTo activate devcon service account impersonation, run:")
343
+ print(f' {YELLOW}eval "$(dh gcp use-devcon --export)"{NC}')
344
+ print("To use your personal account permissions, run:")
345
+ print(f' {YELLOW}eval "$(dh gcp use-user --export)"{NC}')
346
+
347
+
348
+ @gcp_app.command("use-devcon")
349
+ def gcp_use_devcon(
350
+ export: bool = typer.Option(
351
+ False, "--export", "-x", help="Print export commands for the current shell."
352
+ ),
353
+ auth_first: bool = typer.Option(
354
+ False, "--auth", "-a", help="Authenticate user first if needed."
355
+ ),
356
+ ):
357
+ """Switch to devcon service account impersonation mode."""
358
+ if not _is_gcp_user_authenticated():
359
+ if auth_first:
360
+ print(
361
+ f"{YELLOW}You need to authenticate first. Running authentication...{NC}",
362
+ file=sys.stderr,
363
+ )
364
+ _run_gcloud_login()
365
+ else:
366
+ print(
367
+ f"{RED}Error: Not authenticated with GCP. Run 'dh gcp login' first or use --auth flag.{NC}",
368
+ file=sys.stderr,
369
+ )
370
+ sys.exit(1)
371
+
372
+ # Modify RC files to persist across sessions
373
+ _modify_rc_files("CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT", f"'{GCP_DEVCON_SA}'")
374
+ _modify_rc_files("GOOGLE_CLOUD_PROJECT", f"'{GCP_PROJECT_ID}'")
375
+
376
+ if export:
377
+ # Print export commands for the current shell to stdout
378
+ print(f"export CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT='{GCP_DEVCON_SA}'")
379
+ print(f"export GOOGLE_CLOUD_PROJECT='{GCP_PROJECT_ID}'")
380
+
381
+ # Print confirmation to stderr so it doesn't affect eval
382
+ print(
383
+ f"{GREEN}GCP service account impersonation for '{GCP_DEVCON_SA}' set up successfully.{NC}",
384
+ file=sys.stderr,
385
+ )
386
+ print(f"{GREEN}You now have standard devcon permissions.{NC}", file=sys.stderr)
387
+ else:
388
+ # Just print confirmation
389
+ print(
390
+ f"{GREEN}Switched to devcon service account impersonation. You now have standard devcon permissions.{NC}"
391
+ )
392
+ print(
393
+ f"Changes will take effect in new shell sessions. To apply in current shell, run:"
394
+ )
395
+ print(f' {YELLOW}eval "$(dh gcp use-devcon --export)"{NC}')
396
+
397
+
398
+ @gcp_app.command("use-user")
399
+ def gcp_use_user(
400
+ export: bool = typer.Option(
401
+ False, "--export", "-x", help="Print export commands for the current shell."
402
+ ),
403
+ auth_first: bool = typer.Option(
404
+ False, "--auth", "-a", help="Authenticate user first if needed."
405
+ ),
406
+ ):
407
+ """Switch to personal account mode (no impersonation)."""
408
+ if not _is_gcp_user_authenticated():
409
+ if auth_first:
410
+ print(
411
+ f"{YELLOW}You need to authenticate first. Running authentication...{NC}",
412
+ file=sys.stderr,
413
+ )
414
+ _run_gcloud_login()
415
+ else:
416
+ print(
417
+ f"{RED}Error: Not authenticated with GCP. Run 'dh gcp login' first or use --auth flag.{NC}",
418
+ file=sys.stderr,
419
+ )
420
+ sys.exit(1)
421
+
422
+ # Modify RC files to persist across sessions
423
+ _modify_rc_files("CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT", None)
424
+ _modify_rc_files("GOOGLE_CLOUD_PROJECT", f"'{GCP_PROJECT_ID}'")
425
+
426
+ if export:
427
+ # Print export commands for the current shell to stdout
428
+ print(f"unset CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT")
429
+ print(f"export GOOGLE_CLOUD_PROJECT='{GCP_PROJECT_ID}'")
430
+
431
+ # Print confirmation to stderr so it doesn't affect eval
432
+ print(
433
+ f"{GREEN}Switched to personal account mode. You are now using your own permissions.{NC}",
434
+ file=sys.stderr,
435
+ )
436
+ else:
437
+ # Just print confirmation
438
+ print(
439
+ f"{GREEN}Switched to personal account mode. You are now using your own permissions.{NC}"
440
+ )
441
+ print(
442
+ f"Changes will take effect in new shell sessions. To apply in current shell, run:"
443
+ )
444
+ print(f' {YELLOW}eval "$(dh gcp use-user --export)"{NC}')
445
+
446
+
447
+ # --- AWS Commands ---
448
+ @aws_app.command("status")
449
+ def aws_status(
450
+ profile: Optional[str] = typer.Option(
451
+ None, "--profile", "-p", help="Check specific profile instead of current."
452
+ )
453
+ ):
454
+ """Show current AWS authentication status."""
455
+ target_profile = profile or _get_current_aws_profile()
456
+ print(f"{BLUE}AWS profile:{NC} {GREEN}{target_profile}{NC}")
457
+
458
+ if _is_aws_profile_authenticated(target_profile):
459
+ print(f"Credential status: {GREEN}valid{NC}")
460
+ # Get detailed identity information
461
+ aws_path = _find_executable("aws")
462
+ _run_command(
463
+ [aws_path, "sts", "get-caller-identity", "--profile", target_profile]
464
+ )
465
+ else:
466
+ print(f"Credential status: {RED}not authenticated{NC}")
467
+ print(f"\nTo authenticate, run:")
468
+ print(f" {YELLOW}dh aws login --profile {target_profile}{NC}")
469
+
470
+
471
+ @aws_app.command("login")
472
+ def aws_login(
473
+ profile: Optional[str] = typer.Option(
474
+ None, "--profile", "-p", help="Login to specific profile instead of current."
475
+ )
476
+ ):
477
+ """Login to AWS SSO with the specified or current profile."""
478
+ target_profile = profile or _get_current_aws_profile()
479
+ _run_aws_sso_login(target_profile)
480
+ print(f"\nTo activate profile {target_profile} in your current shell, run:")
481
+ print(f' {YELLOW}eval "$(dh aws use-profile {target_profile} --export)"{NC}')
482
+
483
+
484
+ @aws_app.command("use-profile")
485
+ def aws_use_profile(
486
+ profile: str = typer.Argument(..., help="AWS profile name to activate."),
487
+ export: bool = typer.Option(
488
+ False, "--export", "-x", help="Print export commands for the current shell."
489
+ ),
490
+ auto_login: bool = typer.Option(
491
+ False, "--auto-login", "-a", help="Run 'aws sso login' if needed."
492
+ ),
493
+ ):
494
+ """Switch to a specific AWS profile."""
495
+ # Modify RC files to persist across sessions
496
+ _set_aws_profile(profile)
497
+
498
+ if auto_login and not _is_aws_profile_authenticated(profile):
499
+ print(
500
+ f"{YELLOW}Profile '{profile}' not authenticated. Running 'aws sso login'...{NC}",
501
+ file=sys.stderr,
502
+ )
503
+ _run_aws_sso_login(profile)
504
+
505
+ if export:
506
+ # Print export commands for the current shell to stdout
507
+ print(f"export AWS_PROFILE='{profile}'")
508
+ print("unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN")
509
+
510
+ # Print confirmation to stderr so it doesn't affect eval
511
+ print(
512
+ f"{GREEN}AWS profile '{profile}' exported successfully.{NC}",
513
+ file=sys.stderr,
514
+ )
515
+ else:
516
+ # Just print confirmation
517
+ print(f"{GREEN}AWS profile set to '{profile}' and persisted to RC files.{NC}")
518
+ print(
519
+ f"Changes will take effect in new shell sessions. To apply in current shell, run:"
520
+ )
521
+ print(f' {YELLOW}eval "$(dh aws use-profile {profile} --export)"{NC}')
522
+
523
+
524
+ @aws_app.command("interactive")
525
+ def aws_interactive():
526
+ """Launch interactive AWS profile management menu."""
527
+ current_profile = _get_current_aws_profile()
528
+
529
+ print(f"{BLUE}AWS SSO helper – current profile: {GREEN}{current_profile}{NC}")
530
+
531
+ while True:
532
+ choice = questionary.select(
533
+ "Choose an option:",
534
+ choices=[
535
+ f"Authenticate current profile ({current_profile})",
536
+ "Switch profile",
537
+ "Show status",
538
+ "Exit",
539
+ ],
540
+ ).ask()
541
+
542
+ if choice == f"Authenticate current profile ({current_profile})":
543
+ _run_aws_sso_login(current_profile)
544
+ print(f"{GREEN}Authentication complete.{NC}")
545
+ print(f"To activate in your current shell, run:")
546
+ print(
547
+ f' {YELLOW}eval "$(dh aws use-profile {current_profile} --export)"{NC}'
548
+ )
549
+
550
+ elif choice == "Switch profile":
551
+ available_profiles = _get_available_aws_profiles()
552
+
553
+ if not available_profiles:
554
+ print(f"{RED}No AWS profiles found. Check your ~/.aws/config file.{NC}")
555
+ continue
556
+
557
+ for i, prof in enumerate(available_profiles, 1):
558
+ print(f"{i}) {prof}")
559
+
560
+ # Get profile selection by number or name
561
+ sel = questionary.text("Select profile number or name:").ask()
562
+
563
+ if sel.isdigit() and 1 <= int(sel) <= len(available_profiles):
564
+ new_profile = available_profiles[int(sel) - 1]
565
+ elif sel in available_profiles:
566
+ new_profile = sel
567
+ else:
568
+ print(f"{RED}Invalid selection{NC}")
569
+ continue
570
+
571
+ _set_aws_profile(new_profile)
572
+ print(f"{GREEN}Switched to profile {new_profile}{NC}")
573
+ print(f"To activate in your current shell, run:")
574
+ print(f' {YELLOW}eval "$(dh aws use-profile {new_profile} --export)"{NC}')
575
+
576
+ # Ask if they want to authenticate now
577
+ if questionary.confirm(
578
+ "Authenticate this profile now?", default=False
579
+ ).ask():
580
+ _run_aws_sso_login(new_profile)
581
+ print(f"{GREEN}Authentication complete.{NC}")
582
+ print(f"To activate in your current shell, run:")
583
+ print(
584
+ f' {YELLOW}eval "$(dh aws use-profile {new_profile} --export)"{NC}'
585
+ )
586
+
587
+ elif choice == "Show status":
588
+ aws_status()
589
+
590
+ elif choice == "Exit":
591
+ print(f"To activate profile {current_profile} in your current shell, run:")
592
+ print(
593
+ f' {YELLOW}eval "$(dh aws use-profile {current_profile} --export)"{NC}'
594
+ )
595
+ break
596
+
597
+ print() # Add newline between iterations
dayhoff_tools/cli/main.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """Entry file for the CLI, which aggregates and aliases all commands."""
2
2
 
3
3
  import typer
4
+ from dayhoff_tools.cli.cloud_commands import aws_app, gcp_app
4
5
  from dayhoff_tools.cli.utility_commands import (
5
6
  add_to_warehouse_typer,
6
7
  build_and_upload_wheel,
@@ -19,9 +20,26 @@ app.command("gha")(test_github_actions_locally)
19
20
  app.command("rebuild")(rebuild_devcontainer_file)
20
21
  app.command("wadd")(add_to_warehouse_typer)
21
22
  app.command("wancestry")(get_ancestry)
22
- app.command("wheel")(build_and_upload_wheel)
23
23
  app.command("wimport")(import_from_warehouse_typer)
24
24
 
25
+ # Cloud commands
26
+ app.add_typer(gcp_app, name="gcp", help="Manage GCP authentication and impersonation.")
27
+ app.add_typer(aws_app, name="aws", help="Manage AWS SSO authentication.")
28
+
29
+
30
+ @app.command("wheel")
31
+ def build_and_upload_wheel_command(
32
+ bump: str = typer.Option(
33
+ "patch",
34
+ "--bump",
35
+ "-b",
36
+ help="Which part of the version to bump: 'major', 'minor', or 'patch'.",
37
+ case_sensitive=False,
38
+ )
39
+ ):
40
+ """Build wheel, bump version, and upload to PyPI."""
41
+ build_and_upload_wheel(bump_part=bump)
42
+
25
43
 
26
44
  # Use lazy loading for slow-loading swarm commands
27
45
  @app.command("reset")
@@ -1,6 +1,7 @@
1
1
  """CLI commands common to all repos."""
2
2
 
3
3
  import os
4
+ import re
4
5
  import subprocess
5
6
  import sys
6
7
  from pathlib import Path
@@ -164,14 +165,40 @@ def delete_local_branch(branch_name: str, folder_path: str):
164
165
  os.chdir(original_dir)
165
166
 
166
167
 
167
- def build_and_upload_wheel():
168
+ def get_current_version_from_toml(file_path="pyproject.toml"):
169
+ """Reads the version from a pyproject.toml file."""
170
+ try:
171
+ with open(file_path, "r") as f:
172
+ content = f.read()
173
+ version_match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
174
+ if version_match:
175
+ return version_match.group(1)
176
+ else:
177
+ raise ValueError(f"Could not find version string in {file_path}")
178
+ except FileNotFoundError:
179
+ raise FileNotFoundError(f"{file_path} not found.")
180
+ except Exception as e:
181
+ raise e
182
+
183
+
184
+ def build_and_upload_wheel(bump_part: str = "patch"):
168
185
  """Build a Python wheel and upload to PyPI.
169
186
 
170
- Automatically increments the patch version number in pyproject.toml before building.
171
- For example: 1.2.3 -> 1.2.4
187
+ Automatically increments the version number in pyproject.toml before building.
188
+ Use the bump_part argument to specify major, minor, or patch increment.
189
+ For example: 1.2.3 -> 1.2.4 (patch), 1.3.0 (minor), 2.0.0 (major)
172
190
 
173
191
  Expects the PyPI API token to be available in the PYPI_API_TOKEN environment variable.
192
+
193
+ Args:
194
+ bump_part (str): The part of the version to bump: 'major', 'minor', or 'patch'. Defaults to 'patch'.
174
195
  """
196
+ if bump_part not in ["major", "minor", "patch"]:
197
+ print(
198
+ f"Error: Invalid bump_part '{bump_part}'. Must be 'major', 'minor', or 'patch'."
199
+ )
200
+ return
201
+
175
202
  pypi_token = os.environ.get("PYPI_API_TOKEN")
176
203
  if not pypi_token:
177
204
  print("Error: PYPI_API_TOKEN environment variable not set.")
@@ -179,37 +206,45 @@ def build_and_upload_wheel():
179
206
  return
180
207
 
181
208
  try:
182
- # Read current version from pyproject.toml
183
- with open("pyproject.toml", "r") as f:
184
- content = f.read()
209
+ # Get the current version before bumping
210
+ current_version = get_current_version_from_toml()
211
+ print(f"Current version: {current_version}")
185
212
 
186
- # Find version line using simple string search
187
- version_line = [line for line in content.split("\n") if "version = " in line][0]
188
- current_version = version_line.split('"')[1] # Extract version between quotes
213
+ # Use poetry to bump the version in pyproject.toml
214
+ print(f"Bumping {bump_part} version using poetry...")
215
+ subprocess.run(["poetry", "version", bump_part], check=True)
189
216
 
190
- # Increment patch version
191
- major, minor, patch = current_version.split(".")
192
- new_version = f"{major}.{minor}.{int(patch) + 1}"
217
+ # Get the new version after bumping
218
+ new_version = get_current_version_from_toml()
219
+ print(f"New version: {new_version}")
193
220
 
194
- # Update all pyproject files with new version
221
+ # Update other pyproject files with the new version
195
222
  for pyproject_file in [
196
- "pyproject.toml",
197
223
  "pyproject_gcp.toml",
198
224
  "pyproject_mac.toml",
199
225
  ]:
200
226
  try:
201
227
  with open(pyproject_file, "r") as f:
202
228
  content = f.read()
229
+ # Use the current_version read earlier for replacement
203
230
  new_content = content.replace(
204
231
  f'version = "{current_version}"', f'version = "{new_version}"'
205
232
  )
206
- with open(pyproject_file, "w") as f:
207
- f.write(new_content)
208
- print(
209
- f"Version bumped from {current_version} to {new_version} in {pyproject_file}"
210
- )
233
+ if new_content == content:
234
+ print(
235
+ f"Warning: Version string 'version = \"{current_version}\"' not found in {pyproject_file}. No update performed."
236
+ )
237
+ else:
238
+ with open(pyproject_file, "w") as f:
239
+ f.write(new_content)
240
+ print(
241
+ f"Version bumped from {current_version} to {new_version} in {pyproject_file}"
242
+ )
211
243
  except FileNotFoundError:
212
244
  print(f"Skipping {pyproject_file} - file not found")
245
+ except Exception as e:
246
+ print(f"Error updating {pyproject_file}: {e}")
247
+ return # Stop if update fails
213
248
 
214
249
  # Disable keyring to avoid issues in containers/CI
215
250
  print("Disabling Poetry keyring...")
@@ -242,3 +277,5 @@ def build_and_upload_wheel():
242
277
 
243
278
  except subprocess.CalledProcessError as e:
244
279
  print(f"Error during build/upload: {e}")
280
+ except Exception as e:
281
+ print(f"An unexpected error occurred: {e}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: dayhoff-tools
3
- Version: 1.0.0
3
+ Version: 1.0.2
4
4
  Summary: Common tools for all the repos at Dayhoff Labs
5
5
  Author: Daniel Martin-Alarcon
6
6
  Author-email: dma@dayhofflabs.com
@@ -66,57 +66,34 @@ Description-Content-Type: text/markdown
66
66
 
67
67
  # dayhoff-tools
68
68
 
69
- A set of small, sharp tools for everyone at Dayhoff.
69
+ A set of small, sharp tools for everyone at Dayhoff. Hosted on PyPi, so you can Poetry or pip install like everything else.
70
70
 
71
- ## Hosting and Auth
71
+ ## Installation
72
72
 
73
- This repo uses Poetry to build and publish a package to GCP Artifact Registry, at `https://us-central1-python.pkg.dev/enzyme-discovery/pypirate/`. This depends on a Poetry plugin that's now in the standard chassis setup (`keyrings.google-artifactregistry-auth`), and also on the active service account having read access to Artifact Registry. That much is set up for the standard dev container service account, but may not be available to other intended users.
73
+ The base package includes minimal dependencies required for core CLI functionality (like job running):
74
74
 
75
- ## CLI commands
76
-
77
- Unlike all the repos that use dayhoff-tools, here you have to install the package explicitly before using the CLI:
78
-
79
- ```sh
80
- poetry install
75
+ ```bash
76
+ pip install dayhoff-tools
77
+ # or
78
+ poetry add dayhoff-tools
81
79
  ```
82
80
 
83
- ## Publish a new version
84
-
85
- 1. Update version number in `pyproject.toml`
86
- 2. Run `dh wheel`
87
- 3. In other repos, run `poetry update dayhoff-tools`
88
-
89
- If you want to overwrite an existing wheel, you'll have to manually delete it from the `dist` folder and also the [Artifact Registry repo](https://console.cloud.google.com/artifacts/python/enzyme-discovery/us-central1/pypirate/dayhoff-tools).
90
-
91
- ## Install in other repos
92
-
93
- Installing this library is tricky because we need GCS authentication and also a couple of plugins to install this with either Pip or Poetry. These have been incorporated into `chassis`, but it's worth noting here what the various parts are. All this info came from this [Medium post](https://medium.com/google-cloud/python-packages-via-gcps-artifact-registry-ce1714f8e7c1).
81
+ ### Optional Dependencies
94
82
 
95
- 1. Get a Service Account with read access to Artifact Registry (such as `github-actions`, which I made for this purpose).
96
- 2. Export the SA key file, copy it to your repo, and make it available through this envvar: `export GOOGLE_APPLICATION_CREDENTIALS=github_actions_key.json`
83
+ You can install extra sets of dependencies using brackets. Available groups are:
97
84
 
98
- ### ... with Pip
85
+ * `core`: Includes common data science and bioinformatics tools (`biopython`, `boto3`, `docker`, `fair-esm`, `h5py`, `questionary`).
86
+ * `dev`: Includes development and testing tools (`black`, `pytest`, `pandas`, `numpy`, `torch`, etc.).
87
+ * `all`: Includes all dependencies from both `core` and `dev`.
99
88
 
100
- 1. `pip install keyring`
101
- 2. `pip install keyrings.google-artifactregistry-auth`
102
- 3. `pip install --upgrade dayhoff-tools --index-url https://us-central1-python.pkg.dev/enzyme-discovery/pypirate/simple/`
89
+ **Examples:**
103
90
 
104
- ### ... with Poetry
105
-
106
- 1. Add this plugin: `poetry self add keyrings.google-artifactregistry-auth`
107
- 2. Add these sections to `pyproject.toml`. Note that dayhoff-tools is in a separate group `pypirate` that installs separately from the others.
108
-
109
- ```toml
110
- [tool.poetry.group.pypirate.dependencies]
111
- dayhoff-tools = {version = "*", source = "pypirate"}
112
-
113
- [[tool.poetry.source]]
114
- name = "pypirate"
115
- url = "https://us-central1-python.pkg.dev/enzyme-discovery/pypirate/simple/"
116
- priority = "supplemental"
117
- ```
118
-
119
- 3. When building a dev container, or in other circumstances when you can't easily authenticate as above, run `poetry install --without pypirate`.
120
- 4. Otherwise, just `poetry install`.
121
- 5. To ensure you have the latest version, run `poetry update dayhoff-tools`.
91
+ ```bash
92
+ # Install with core dependencies
93
+ pip install 'dayhoff-tools[core]'
94
+ poetry add 'dayhoff-tools[core]'
122
95
 
96
+ # Install with all dependencies
97
+ pip install 'dayhoff-tools[all]'
98
+ poetry add 'dayhoff-tools[all]'
99
+ ```
@@ -2,9 +2,10 @@ dayhoff_tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  dayhoff_tools/chemistry/standardizer.py,sha256=uMn7VwHnx02nc404eO6fRuS4rsl4dvSPf2ElfZDXEpY,11188
3
3
  dayhoff_tools/chemistry/utils.py,sha256=jt-7JgF-GeeVC421acX-bobKbLU_X94KNOW24p_P-_M,2257
4
4
  dayhoff_tools/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
- dayhoff_tools/cli/main.py,sha256=pIVwkeewZcTCAl6lM7EOXjggWDBzTR_JF5Dtwndvvfw,2978
5
+ dayhoff_tools/cli/cloud_commands.py,sha256=XTHylqeZ-CLly_wl--7xQq9Ure_wJbzRZjUIzWhHKiI,20811
6
+ dayhoff_tools/cli/main.py,sha256=DJwtU-D-UDzlj_SOZXzw61a2Jg7hvOQLfwBZCcV3eig,3534
6
7
  dayhoff_tools/cli/swarm_commands.py,sha256=5EyKj8yietvT5lfoz8Zx0iQvVaNgc3SJX1z2zQR6o6M,5614
7
- dayhoff_tools/cli/utility_commands.py,sha256=2J69bfOvQV9E8wWynYhUXZ89BfhdxbGm1OXxXkZgVC0,8760
8
+ dayhoff_tools/cli/utility_commands.py,sha256=TQsPbim2RBvkx7I6ZfYxtbBJF2m1jZi6X5W0f8NUvVc,10314
8
9
  dayhoff_tools/deployment/base.py,sha256=u-AjbtHnFLoLt33dhYXHIpV-6jcieMEHHGBGN_U9Hm0,15626
9
10
  dayhoff_tools/deployment/deploy_aws.py,sha256=O0gQxHioSU_sNU8T8MD4wSOPvWc--V8eRRZzlRu035I,16446
10
11
  dayhoff_tools/deployment/deploy_gcp.py,sha256=DxBM4sUzwPK9RWLP9bSfr38n1HHl-TVrp4TsbdN8pUA,5795
@@ -24,7 +25,7 @@ dayhoff_tools/sqlite.py,sha256=jV55ikF8VpTfeQqqlHSbY8OgfyfHj8zgHNpZjBLos_E,18672
24
25
  dayhoff_tools/structure.py,sha256=ufN3gAodQxhnt7psK1VTQeu9rKERmo_PhoxIbB4QKMw,27660
25
26
  dayhoff_tools/uniprot.py,sha256=BZYJQF63OtPcBBnQ7_P9gulxzJtqyorgyuDiPeOJqE4,16456
26
27
  dayhoff_tools/warehouse.py,sha256=TqV8nex1AluNaL4JuXH5zuu9P7qmE89lSo6f_oViy6U,14965
27
- dayhoff_tools-1.0.0.dist-info/METADATA,sha256=PTETz-zfValRWDwYlgilMcmfrK7lHaAbT6iXLTCinKs,5773
28
- dayhoff_tools-1.0.0.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
29
- dayhoff_tools-1.0.0.dist-info/entry_points.txt,sha256=iAf4jteNqW3cJm6CO6czLxjW3vxYKsyGLZ8WGmxamSc,49
30
- dayhoff_tools-1.0.0.dist-info/RECORD,,
28
+ dayhoff_tools-1.0.2.dist-info/METADATA,sha256=KNTdcWItPgKu6tQ-i4izsDL8Pfwbnyo40thpyPBmHtE,3949
29
+ dayhoff_tools-1.0.2.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
30
+ dayhoff_tools-1.0.2.dist-info/entry_points.txt,sha256=iAf4jteNqW3cJm6CO6czLxjW3vxYKsyGLZ8WGmxamSc,49
31
+ dayhoff_tools-1.0.2.dist-info/RECORD,,