crunr 0.1.0__tar.gz

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.
crunr-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: crunr
3
+ Version: 0.1.0
4
+ Summary: Run any compute job on AWS with a single command
5
+ License-Expression: MIT
6
+ Keywords: aws,ec2,cloud,gpu,machine-learning,spot
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Environment :: Console
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
16
+ Classifier: Topic :: System :: Distributed Computing
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: boto3>=1.34
20
+ Requires-Dist: botocore>=1.34
21
+ Requires-Dist: rich>=13.7
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7.4; extra == "dev"
24
+ Requires-Dist: pytest-mock>=3.12; extra == "dev"
25
+ Requires-Dist: moto[ec2,sts]>=5.0; extra == "dev"
26
+ Requires-Dist: ruff>=0.3; extra == "dev"
27
+ Requires-Dist: mypy>=1.8; extra == "dev"
28
+ Requires-Dist: boto3-stubs[ec2,sts]>=1.34; extra == "dev"
29
+
30
+ # crunr
31
+
32
+ Run any compute job on AWS EC2 with a single command — no DevOps required.
33
+
34
+ ```
35
+ crunr run train.py --gpu
36
+ ```
37
+
38
+ crunr provisions an instance, uploads your code, streams live output, downloads results, and terminates the instance automatically. Zero idle cost.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install crunr
44
+ ```
45
+
46
+ Requires Python 3.10+ and an AWS account.
47
+
48
+ ## Quick start
49
+
50
+ ```bash
51
+ # 1. Configure AWS credentials (one-time)
52
+ crunr auth
53
+
54
+ # 2. Run a script on the cheapest CPU instance
55
+ crunr run script.py
56
+
57
+ # 3. Run on a GPU instance
58
+ crunr run train.py --gpu
59
+
60
+ # 4. Specify minimum VRAM
61
+ crunr run train.py --gpu --memory 24
62
+
63
+ # 5. Pass environment variables
64
+ crunr run train.py --env EPOCHS=50 --env LR=0.001
65
+ ```
66
+
67
+ ## How it works
68
+
69
+ 1. **Provision** — selects the cheapest matching spot instance, falls back to on-demand
70
+ 2. **Sync** — uploads your local directory to the instance (rsync or scp+tar)
71
+ 3. **Execute** — runs your command with live log streaming
72
+ 4. **Collect** — downloads any `outputs/` directory back to your machine
73
+ 5. **Terminate** — instance is always destroyed, even on Ctrl+C or crash
74
+
75
+ ## Commands
76
+
77
+ | Command | Description |
78
+ |---|---|
79
+ | `crunr auth` | Configure AWS credentials |
80
+ | `crunr run <script>` | Run a job on EC2 |
81
+ | `crunr jobs` | Show job history |
82
+ | `crunr ps` | List running instances |
83
+ | `crunr clean` | Terminate all orphaned instances |
84
+
85
+ ## `crunr run` options
86
+
87
+ ```
88
+ --gpu Request a GPU instance (cheapest available)
89
+ --memory GB Minimum GPU VRAM or RAM in GB
90
+ --instance TYPE Exact EC2 instance type (e.g. g5.xlarge)
91
+ --disk GB Root EBS volume size (default: 8 GB CPU, 100 GB GPU)
92
+ --env KEY=VALUE Environment variable passed to the job (repeatable)
93
+ --dir PATH Local directory to sync (default: current directory)
94
+ --on-demand Use on-demand pricing instead of spot
95
+ --profile NAME AWS credential profile
96
+ --region REGION Override AWS region
97
+ ```
98
+
99
+ ## Saving outputs
100
+
101
+ Your script can write files to an `outputs/` directory. crunr automatically downloads it after the job finishes:
102
+
103
+ ```python
104
+ import os
105
+ os.makedirs("outputs", exist_ok=True)
106
+ with open("outputs/result.txt", "w") as f:
107
+ f.write("done")
108
+ ```
109
+
110
+ ## AWS IAM permissions
111
+
112
+ The IAM user needs these permissions:
113
+
114
+ ```json
115
+ {
116
+ "Version": "2012-10-17",
117
+ "Statement": [
118
+ {
119
+ "Effect": "Allow",
120
+ "Action": [
121
+ "ec2:RunInstances", "ec2:TerminateInstances", "ec2:DescribeInstances",
122
+ "ec2:DescribeImages", "ec2:DescribeSpotPriceHistory",
123
+ "ec2:CreateKeyPair", "ec2:DeleteKeyPair", "ec2:DescribeKeyPairs",
124
+ "ec2:CreateSecurityGroup", "ec2:DescribeSecurityGroups",
125
+ "ec2:AuthorizeSecurityGroupIngress",
126
+ "ec2:CreateTags",
127
+ "sts:GetCallerIdentity"
128
+ ],
129
+ "Resource": "*"
130
+ },
131
+ {
132
+ "Effect": "Allow",
133
+ "Action": "iam:CreateServiceLinkedRole",
134
+ "Resource": "arn:aws:iam::*:role/aws-service-role/spot.amazonaws.com/*"
135
+ }
136
+ ]
137
+ }
138
+ ```
139
+
140
+ ## Cost
141
+
142
+ You only pay for the time the instance runs. Spot instances are typically **60–90% cheaper** than on-demand.
143
+
144
+ Example costs (spot, us-east-1):
145
+ - `t3.micro` CPU job — ~$0.003/hr
146
+ - `g4dn.xlarge` GPU (T4) — ~$0.16/hr
147
+ - `g5.xlarge` GPU (A10G) — ~$0.34/hr
148
+
149
+ Data transfer into EC2 is free. Transfer out costs ~$0.09/GB.
150
+
151
+ ## License
152
+
153
+ MIT
crunr-0.1.0/README.md ADDED
@@ -0,0 +1,124 @@
1
+ # crunr
2
+
3
+ Run any compute job on AWS EC2 with a single command — no DevOps required.
4
+
5
+ ```
6
+ crunr run train.py --gpu
7
+ ```
8
+
9
+ crunr provisions an instance, uploads your code, streams live output, downloads results, and terminates the instance automatically. Zero idle cost.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install crunr
15
+ ```
16
+
17
+ Requires Python 3.10+ and an AWS account.
18
+
19
+ ## Quick start
20
+
21
+ ```bash
22
+ # 1. Configure AWS credentials (one-time)
23
+ crunr auth
24
+
25
+ # 2. Run a script on the cheapest CPU instance
26
+ crunr run script.py
27
+
28
+ # 3. Run on a GPU instance
29
+ crunr run train.py --gpu
30
+
31
+ # 4. Specify minimum VRAM
32
+ crunr run train.py --gpu --memory 24
33
+
34
+ # 5. Pass environment variables
35
+ crunr run train.py --env EPOCHS=50 --env LR=0.001
36
+ ```
37
+
38
+ ## How it works
39
+
40
+ 1. **Provision** — selects the cheapest matching spot instance, falls back to on-demand
41
+ 2. **Sync** — uploads your local directory to the instance (rsync or scp+tar)
42
+ 3. **Execute** — runs your command with live log streaming
43
+ 4. **Collect** — downloads any `outputs/` directory back to your machine
44
+ 5. **Terminate** — instance is always destroyed, even on Ctrl+C or crash
45
+
46
+ ## Commands
47
+
48
+ | Command | Description |
49
+ |---|---|
50
+ | `crunr auth` | Configure AWS credentials |
51
+ | `crunr run <script>` | Run a job on EC2 |
52
+ | `crunr jobs` | Show job history |
53
+ | `crunr ps` | List running instances |
54
+ | `crunr clean` | Terminate all orphaned instances |
55
+
56
+ ## `crunr run` options
57
+
58
+ ```
59
+ --gpu Request a GPU instance (cheapest available)
60
+ --memory GB Minimum GPU VRAM or RAM in GB
61
+ --instance TYPE Exact EC2 instance type (e.g. g5.xlarge)
62
+ --disk GB Root EBS volume size (default: 8 GB CPU, 100 GB GPU)
63
+ --env KEY=VALUE Environment variable passed to the job (repeatable)
64
+ --dir PATH Local directory to sync (default: current directory)
65
+ --on-demand Use on-demand pricing instead of spot
66
+ --profile NAME AWS credential profile
67
+ --region REGION Override AWS region
68
+ ```
69
+
70
+ ## Saving outputs
71
+
72
+ Your script can write files to an `outputs/` directory. crunr automatically downloads it after the job finishes:
73
+
74
+ ```python
75
+ import os
76
+ os.makedirs("outputs", exist_ok=True)
77
+ with open("outputs/result.txt", "w") as f:
78
+ f.write("done")
79
+ ```
80
+
81
+ ## AWS IAM permissions
82
+
83
+ The IAM user needs these permissions:
84
+
85
+ ```json
86
+ {
87
+ "Version": "2012-10-17",
88
+ "Statement": [
89
+ {
90
+ "Effect": "Allow",
91
+ "Action": [
92
+ "ec2:RunInstances", "ec2:TerminateInstances", "ec2:DescribeInstances",
93
+ "ec2:DescribeImages", "ec2:DescribeSpotPriceHistory",
94
+ "ec2:CreateKeyPair", "ec2:DeleteKeyPair", "ec2:DescribeKeyPairs",
95
+ "ec2:CreateSecurityGroup", "ec2:DescribeSecurityGroups",
96
+ "ec2:AuthorizeSecurityGroupIngress",
97
+ "ec2:CreateTags",
98
+ "sts:GetCallerIdentity"
99
+ ],
100
+ "Resource": "*"
101
+ },
102
+ {
103
+ "Effect": "Allow",
104
+ "Action": "iam:CreateServiceLinkedRole",
105
+ "Resource": "arn:aws:iam::*:role/aws-service-role/spot.amazonaws.com/*"
106
+ }
107
+ ]
108
+ }
109
+ ```
110
+
111
+ ## Cost
112
+
113
+ You only pay for the time the instance runs. Spot instances are typically **60–90% cheaper** than on-demand.
114
+
115
+ Example costs (spot, us-east-1):
116
+ - `t3.micro` CPU job — ~$0.003/hr
117
+ - `g4dn.xlarge` GPU (T4) — ~$0.16/hr
118
+ - `g5.xlarge` GPU (A10G) — ~$0.34/hr
119
+
120
+ Data transfer into EC2 is free. Transfer out costs ~$0.09/GB.
121
+
122
+ ## License
123
+
124
+ MIT
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,230 @@
1
+ """AWS credential management: interactive wizard, list, verify, set-default."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import configparser
6
+ import os
7
+ import stat
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ import boto3
13
+ import botocore.exceptions
14
+
15
+ from . import console
16
+ from .config import REGIONS
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # File paths
20
+ # ---------------------------------------------------------------------------
21
+
22
+ AWS_DIR = Path.home() / ".aws"
23
+ CREDS_FILE = AWS_DIR / "credentials"
24
+ CONFIG_FILE = AWS_DIR / "config"
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Public API
29
+ # ---------------------------------------------------------------------------
30
+
31
+ def cmd_auth(profile_name: str = "default") -> None:
32
+ """Interactive wizard to add/update an AWS profile."""
33
+ console.header(
34
+ "crunr auth",
35
+ f"Configure AWS credentials → profile: [bold]{profile_name}[/bold]",
36
+ )
37
+
38
+ console.step("Enter your AWS credentials")
39
+ console.info(" Find these at: AWS Console → IAM → Users → Security credentials")
40
+
41
+ key_id = console.prompt("AWS Access Key ID")
42
+ if not key_id:
43
+ console.error("Access Key ID cannot be empty.")
44
+ sys.exit(1)
45
+
46
+ secret = console.prompt_secret("AWS Secret Access Key")
47
+ if not secret:
48
+ console.error("Secret Access Key cannot be empty.")
49
+ sys.exit(1)
50
+
51
+ region = console.choose("Select a region", REGIONS)
52
+
53
+ _ensure_aws_dir()
54
+ _write_credentials(profile_name, key_id, secret)
55
+ _write_config(profile_name, region)
56
+
57
+ console.ok(f"Credentials written to {CREDS_FILE}")
58
+
59
+ with console.spinner("Verifying credentials with AWS STS…"):
60
+ ok, identity, err = _verify(profile_name)
61
+
62
+ if ok:
63
+ console.ok(f"Authenticated as: [bold]{identity}[/bold]")
64
+ if profile_name != "default":
65
+ console.info(f" Use with: crunr run script.py --profile {profile_name}")
66
+ else:
67
+ console.error(f"Credential verification failed: {err}")
68
+ console.warn("Credentials were saved but may be invalid. Double-check them with:")
69
+ console.info(f" crunr auth --verify {profile_name}")
70
+ sys.exit(1)
71
+
72
+
73
+ def cmd_list_profiles() -> None:
74
+ """Print a table of all configured profiles."""
75
+ profiles = _read_all_profiles()
76
+ default = _read_default_profile_name()
77
+ for p in profiles:
78
+ p["is_default"] = (p["name"] == default)
79
+ console.profiles_table(profiles)
80
+
81
+
82
+ def cmd_verify(profile_name: str) -> None:
83
+ """Test that a profile's credentials still work."""
84
+ with console.spinner(f"Verifying profile [bold]{profile_name}[/bold]…"):
85
+ ok, identity, err = _verify(profile_name)
86
+ if ok:
87
+ console.ok(f"Profile [bold]{profile_name}[/bold] is valid — authenticated as [bold]{identity}[/bold]")
88
+ else:
89
+ console.error(f"Profile [bold]{profile_name}[/bold] failed: {err}")
90
+ sys.exit(1)
91
+
92
+
93
+ def cmd_set_default(profile_name: str) -> None:
94
+ """Copy an existing profile's settings into the [default] slot."""
95
+ profiles = {p["name"]: p for p in _read_all_profiles()}
96
+ if profile_name not in profiles:
97
+ console.error(f"Profile '{profile_name}' not found. Run 'crunr auth --list' to see available profiles.")
98
+ sys.exit(1)
99
+
100
+ p = profiles[profile_name]
101
+ _ensure_aws_dir()
102
+
103
+ # Read key and secret from the existing profile entry
104
+ creds = _read_configparser(CREDS_FILE)
105
+ section = profile_name if profile_name != "default" else "default"
106
+ if not creds.has_section(section):
107
+ console.error(f"No credentials found for profile '{profile_name}'.")
108
+ sys.exit(1)
109
+
110
+ key_id = creds.get(section, "aws_access_key_id", fallback="")
111
+ secret = creds.get(section, "aws_secret_access_key", fallback="")
112
+ _write_credentials("default", key_id, secret)
113
+ _write_config("default", p["region"])
114
+
115
+ console.ok(f"Default profile now points to [bold]{profile_name}[/bold] ({p['region']})")
116
+
117
+
118
+ def get_boto3_session(profile: Optional[str] = None, region: Optional[str] = None) -> boto3.Session:
119
+ """Return a boto3 Session using the given profile (or default)."""
120
+ try:
121
+ session = boto3.Session(profile_name=profile, region_name=region)
122
+ # Force credential resolution now so errors surface early
123
+ creds = session.get_credentials()
124
+ if creds is None:
125
+ raise RuntimeError("No AWS credentials found.")
126
+ return session
127
+ except botocore.exceptions.ProfileNotFound:
128
+ console.error(
129
+ f"AWS profile '{profile}' not found.\n"
130
+ " Run 'crunr auth' to configure credentials, or 'crunr auth --list' to see profiles."
131
+ )
132
+ sys.exit(1)
133
+ except botocore.exceptions.NoCredentialsError:
134
+ console.error(
135
+ "No AWS credentials found.\n"
136
+ " Run 'crunr auth' to set up your AWS credentials."
137
+ )
138
+ sys.exit(1)
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # Internal helpers
143
+ # ---------------------------------------------------------------------------
144
+
145
+ def _ensure_aws_dir() -> None:
146
+ AWS_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
147
+
148
+
149
+ def _read_configparser(path: Path) -> configparser.ConfigParser:
150
+ cp = configparser.ConfigParser()
151
+ if path.exists():
152
+ cp.read(path)
153
+ return cp
154
+
155
+
156
+ def _write_credentials(profile_name: str, key_id: str, secret: str) -> None:
157
+ cp = _read_configparser(CREDS_FILE)
158
+ section = profile_name # credentials file uses bare profile names
159
+ if not cp.has_section(section):
160
+ cp.add_section(section)
161
+ cp.set(section, "aws_access_key_id", key_id)
162
+ cp.set(section, "aws_secret_access_key", secret)
163
+
164
+ with CREDS_FILE.open("w") as f:
165
+ cp.write(f)
166
+
167
+ _set_private(CREDS_FILE)
168
+
169
+
170
+ def _write_config(profile_name: str, region: str) -> None:
171
+ cp = _read_configparser(CONFIG_FILE)
172
+ # config file uses "profile <name>" for non-default profiles
173
+ section = "default" if profile_name == "default" else f"profile {profile_name}"
174
+ if not cp.has_section(section):
175
+ cp.add_section(section)
176
+ cp.set(section, "region", region)
177
+ cp.set(section, "output", "json")
178
+
179
+ with CONFIG_FILE.open("w") as f:
180
+ cp.write(f)
181
+
182
+ _set_private(CONFIG_FILE)
183
+
184
+
185
+ def _set_private(path: Path) -> None:
186
+ try:
187
+ path.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0o600
188
+ except OSError:
189
+ pass # Windows — best effort
190
+
191
+
192
+ def _verify(profile_name: str) -> tuple[bool, str, str]:
193
+ try:
194
+ session = boto3.Session(profile_name=profile_name)
195
+ sts = session.client("sts")
196
+ identity = sts.get_caller_identity()
197
+ arn = identity.get("Arn", "unknown")
198
+ return True, arn, ""
199
+ except botocore.exceptions.ProfileNotFound as e:
200
+ return False, "", str(e)
201
+ except botocore.exceptions.ClientError as e:
202
+ return False, "", str(e)
203
+ except botocore.exceptions.NoCredentialsError as e:
204
+ return False, "", str(e)
205
+ except Exception as e: # noqa: BLE001
206
+ return False, "", str(e)
207
+
208
+
209
+ def _read_all_profiles() -> list[dict]: # type: ignore[type-arg]
210
+ cp_creds = _read_configparser(CREDS_FILE)
211
+ cp_conf = _read_configparser(CONFIG_FILE)
212
+
213
+ profiles = []
214
+ for section in cp_creds.sections():
215
+ key_id = cp_creds.get(section, "aws_access_key_id", fallback="")
216
+ # Mask key for display
217
+ masked = key_id[:4] + "****" + key_id[-4:] if len(key_id) >= 8 else "****"
218
+
219
+ # Find region from config file
220
+ conf_section = "default" if section == "default" else f"profile {section}"
221
+ region = cp_conf.get(conf_section, "region", fallback="us-east-1")
222
+
223
+ profiles.append({"name": section, "region": region, "key_id": masked})
224
+
225
+ return profiles
226
+
227
+
228
+ def _read_default_profile_name() -> str:
229
+ """Returns the name of the current default profile (always 'default')."""
230
+ return "default"