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.
- dayhoff_tools/cli/cloud_commands.py +597 -0
- dayhoff_tools/cli/main.py +19 -1
- dayhoff_tools/cli/utility_commands.py +56 -19
- {dayhoff_tools-1.0.0.dist-info → dayhoff_tools-1.0.2.dist-info}/METADATA +22 -45
- {dayhoff_tools-1.0.0.dist-info → dayhoff_tools-1.0.2.dist-info}/RECORD +7 -6
- {dayhoff_tools-1.0.0.dist-info → dayhoff_tools-1.0.2.dist-info}/WHEEL +0 -0
- {dayhoff_tools-1.0.0.dist-info → dayhoff_tools-1.0.2.dist-info}/entry_points.txt +0 -0
@@ -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
|
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
|
171
|
-
|
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
|
-
#
|
183
|
-
|
184
|
-
|
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
|
-
#
|
187
|
-
|
188
|
-
|
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
|
-
#
|
191
|
-
|
192
|
-
|
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
|
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
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
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.
|
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
|
-
##
|
71
|
+
## Installation
|
72
72
|
|
73
|
-
|
73
|
+
The base package includes minimal dependencies required for core CLI functionality (like job running):
|
74
74
|
|
75
|
-
|
76
|
-
|
77
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
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/
|
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=
|
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.
|
28
|
-
dayhoff_tools-1.0.
|
29
|
-
dayhoff_tools-1.0.
|
30
|
-
dayhoff_tools-1.0.
|
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,,
|
File without changes
|
File without changes
|