evercoast-cli 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.
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: evercoast-cli
3
+ Version: 0.1.0
4
+ Summary: Evercoast CLI — upload files to your company's S3 bucket
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: click>=8.0
7
+ Requires-Dist: boto3>=1.28
8
+ Requires-Dist: requests>=2.28
9
+ Requires-Dist: rich>=13.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=7.0; extra == "dev"
12
+ Requires-Dist: pytest-mock>=3.10; extra == "dev"
@@ -0,0 +1,166 @@
1
+ # Evercoast CLI
2
+
3
+ Upload files to your company's S3 bucket with resumable uploads, checksum validation, and automatic credential management.
4
+
5
+ ## Requirements
6
+
7
+ - Python 3.9+
8
+ - An active Evercoast account with S3 direct upload enabled by Evercoast support
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install evercoast-cli
14
+ ```
15
+
16
+ ## Quick Start
17
+
18
+ ### 1. Login (one-time setup)
19
+
20
+ ```bash
21
+ evercoast login
22
+ ```
23
+
24
+ You'll be prompted for your Evercoast email and password. You only need to do this once — credentials are saved and refresh automatically.
25
+
26
+ ### 2. Upload
27
+
28
+ ```bash
29
+ evercoast upload ./session-42/ --type takes
30
+ ```
31
+
32
+ That's it. The CLI handles credentials, checksums, progress, and retries automatically.
33
+
34
+ ---
35
+
36
+ ## Upload Guide
37
+
38
+ ### Data types
39
+
40
+ Choose a data type to route files to the right location:
41
+
42
+ ```bash
43
+ evercoast upload ./session-42/ --type takes # Raw capture data
44
+ evercoast upload ./meshes/ --type renders # PLY or OBJ sequences
45
+ evercoast upload notes.zip --type other # Anything else
46
+ ```
47
+
48
+ If you don't pass `--type`, the CLI prompts you:
49
+
50
+ ```
51
+ What type of data are you uploading?
52
+
53
+ 1) Raw take data (images, videos, calibration, 2D data)
54
+ 2) PLY or OBJ sequences
55
+ 3) Other data
56
+
57
+ Select [1-3] (default: 1):
58
+ ```
59
+
60
+ Your last choice is remembered for next time.
61
+
62
+ | Type | Destination |
63
+ |------|-------------|
64
+ | `takes` | `client-uploads/takes/<folder-name>/` |
65
+ | `renders` | `client-uploads/renders/<folder-name>/` |
66
+ | `other` | `client-uploads/<folder-name>/` |
67
+
68
+ Use `--to` to specify a custom destination:
69
+
70
+ ```bash
71
+ evercoast upload ./data/ --to my/custom/path
72
+ # → uploads to client-uploads/my/custom/path/
73
+ ```
74
+
75
+ ### Resumable uploads
76
+
77
+ Directories are synced. If an upload is interrupted (Ctrl+C, network drop, laptop sleep), re-run the same command and only the remaining files are uploaded:
78
+
79
+ ```bash
80
+ # First run: uploads 847 files
81
+ evercoast upload ./session-42/ --type takes
82
+
83
+ # Second run: skips everything
84
+ evercoast upload ./session-42/ --type takes
85
+ # → "Nothing to upload — all 847 files already exist at destination."
86
+ ```
87
+
88
+ ### Checksum validation
89
+
90
+ SHA256 checksums are enabled by default. The CLI calculates a checksum locally and the server validates it — if data is corrupted in transit, the upload is rejected and retried.
91
+
92
+ Disable with `--no-checksum` if needed (not recommended).
93
+
94
+ ### Network resilience
95
+
96
+ If the network drops during an upload, the CLI:
97
+
98
+ 1. Detects the failure
99
+ 2. Waits for connectivity to return
100
+ 3. Retries with increasing delays (up to 5 minutes between attempts)
101
+
102
+ Retries continue indefinitely until the upload succeeds or you cancel with Ctrl+C. If you cancel, re-run the same command to resume where you left off.
103
+
104
+ ### Dry run
105
+
106
+ Preview what would be uploaded without actually uploading:
107
+
108
+ ```bash
109
+ evercoast upload ./session-42/ --type takes --dry-run
110
+ ```
111
+
112
+ ### Exclude files
113
+
114
+ Skip files matching glob patterns:
115
+
116
+ ```bash
117
+ evercoast upload ./data/ --type takes --exclude "*.tmp" --exclude ".DS_Store"
118
+ ```
119
+
120
+ ### Skip confirmation
121
+
122
+ Uploads over 1 GB prompt for confirmation. Skip with `--yes`:
123
+
124
+ ```bash
125
+ evercoast upload ./large-dataset/ --type takes --yes
126
+ ```
127
+
128
+ ---
129
+
130
+ ## All Options
131
+
132
+ ```
133
+ evercoast login
134
+ Authenticate and save credentials (one-time setup)
135
+
136
+ evercoast upload <path> [options]
137
+ --type [takes|renders|other] Data type (prompted if omitted)
138
+ --to TEXT Custom destination path
139
+ --dry-run Preview without uploading
140
+ --no-checksum Disable SHA256 checksum validation
141
+ --exclude TEXT Exclude glob pattern (repeatable)
142
+ -y, --yes Skip confirmation prompt
143
+ --help Show help
144
+ ```
145
+
146
+ ## Troubleshooting
147
+
148
+ ### "Not configured. Run 'evercoast login' first."
149
+
150
+ You haven't logged in yet. Run `evercoast login` and enter your Evercoast credentials.
151
+
152
+ ### "Authentication expired."
153
+
154
+ Your saved credentials are no longer valid. Run `evercoast login` again.
155
+
156
+ ### "S3 direct upload is not enabled for your company."
157
+
158
+ Contact Evercoast support to enable this feature for your company.
159
+
160
+ ### Upload seems stuck
161
+
162
+ Large files use multipart upload — the progress bar may pause between parts. This is normal. If the upload truly stalls, Ctrl+C and re-run the same command to resume.
163
+
164
+ ## Support
165
+
166
+ Contact Evercoast support or your account representative for help.
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "evercoast-cli"
7
+ version = "0.1.0"
8
+ description = "Evercoast CLI — upload files to your company's S3 bucket"
9
+ requires-python = ">=3.9"
10
+ dependencies = [
11
+ "click>=8.0",
12
+ "boto3>=1.28",
13
+ "requests>=2.28",
14
+ "rich>=13.0",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ dev = [
19
+ "pytest>=7.0",
20
+ "pytest-mock>=3.10",
21
+ ]
22
+
23
+ [project.scripts]
24
+ evercoast = "evercoast_cli.main:cli"
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Evercoast CLI — upload files to your company's S3 bucket."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,264 @@
1
+ """API client for Evercoast backend.
2
+
3
+ Handles login, S3 credential fetching, and auto-refreshing boto3 sessions.
4
+ """
5
+
6
+ import platform
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Dict, Optional
11
+
12
+ import boto3
13
+ import requests
14
+ from botocore.credentials import RefreshableCredentials
15
+ from botocore.session import get_session
16
+
17
+ from . import __version__
18
+
19
+ EC_AGENT_HEADER = "CB"
20
+ USER_AGENT = f"evercoast-cli/{__version__} ({platform.system()})"
21
+
22
+ # Common headers for all API calls
23
+ BASE_HEADERS = {
24
+ "EC-Agent": EC_AGENT_HEADER,
25
+ "Content-Type": "application/json",
26
+ "User-Agent": USER_AGENT,
27
+ }
28
+
29
+
30
+ class APIError(Exception):
31
+ """Raised when an API call fails."""
32
+
33
+ def __init__(self, message: str, status_code: Optional[int] = None):
34
+ self.status_code = status_code
35
+ super().__init__(message)
36
+
37
+
38
+ def login(api_url: str, email: str, password: str) -> str:
39
+ """Authenticate with the Evercoast API and return an auth token.
40
+
41
+ Uses EC-Agent: CB to create a Cloudbreak token that persists
42
+ indefinitely and won't conflict with Mavericks sessions.
43
+
44
+ Returns:
45
+ Auth token string
46
+
47
+ Raises:
48
+ APIError: If login fails
49
+ """
50
+ try:
51
+ resp = requests.post(
52
+ f"{api_url}/login/",
53
+ headers=BASE_HEADERS,
54
+ json={"email": email, "password": password},
55
+ timeout=30,
56
+ )
57
+ except requests.ConnectionError:
58
+ raise APIError("Could not connect to Evercoast API. Check your internet connection.")
59
+ except requests.Timeout:
60
+ raise APIError("Connection to Evercoast API timed out.")
61
+
62
+ if resp.status_code != 200:
63
+ data = {}
64
+ try:
65
+ data = resp.json()
66
+ except ValueError:
67
+ pass
68
+ msg = (
69
+ data.get("detail")
70
+ or data.get("error")
71
+ or (data.get("non_field_errors") or [""])[0]
72
+ or "Invalid credentials"
73
+ )
74
+ raise APIError(f"Login failed: {msg}", status_code=resp.status_code)
75
+
76
+ data = resp.json()
77
+ token = data.get("token")
78
+ if not token:
79
+ raise APIError("Login failed: no token received")
80
+
81
+ return token
82
+
83
+
84
+ def fetch_s3_credentials(api_url: str, token: str) -> Dict:
85
+ """Fetch S3 upload credentials from the API.
86
+
87
+ Returns:
88
+ Dict with keys: access_key_id, secret_access_key, session_token,
89
+ expiration, bucket, region, upload_path
90
+
91
+ Raises:
92
+ APIError: If credential fetch fails
93
+ """
94
+ headers = {
95
+ **BASE_HEADERS,
96
+ "Authorization": f"Token {token}",
97
+ }
98
+
99
+ try:
100
+ resp = requests.post(
101
+ f"{api_url}/s3-credentials/",
102
+ headers=headers,
103
+ timeout=30,
104
+ )
105
+ except requests.ConnectionError:
106
+ raise APIError("Could not connect to Evercoast API. Check your internet connection.")
107
+ except requests.Timeout:
108
+ raise APIError("Connection to Evercoast API timed out.")
109
+
110
+ if resp.status_code == 401:
111
+ raise APIError(
112
+ "Authentication expired. Run 'evercoast login' to re-authenticate.",
113
+ status_code=401,
114
+ )
115
+
116
+ if resp.status_code != 200:
117
+ data = {}
118
+ try:
119
+ data = resp.json()
120
+ except ValueError:
121
+ pass
122
+ msg = data.get("error") or data.get("detail") or "Unknown error"
123
+ raise APIError(f"Failed to get S3 credentials: {msg}", status_code=resp.status_code)
124
+
125
+ return resp.json()
126
+
127
+
128
+ def _make_refreshable_metadata(api_url: str, token: str) -> Dict[str, str]:
129
+ """Fetch credentials and return in botocore RefreshableCredentials format."""
130
+ data = fetch_s3_credentials(api_url, token)
131
+ return {
132
+ "access_key": data["access_key_id"],
133
+ "secret_key": data["secret_access_key"],
134
+ "token": data["session_token"],
135
+ "expiry_time": data["expiration"],
136
+ }
137
+
138
+
139
+ def get_s3_client(api_url: str, token: str, region: str):
140
+ """Create an S3 client with auto-refreshing credentials.
141
+
142
+ Uses botocore's RefreshableCredentials so credentials are automatically
143
+ refreshed before expiration during long uploads.
144
+ """
145
+ credentials = RefreshableCredentials.create_from_metadata(
146
+ metadata=_make_refreshable_metadata(api_url, token),
147
+ refresh_using=lambda: _make_refreshable_metadata(api_url, token),
148
+ method="sts-assume-role",
149
+ )
150
+
151
+ botocore_session = get_session()
152
+ botocore_session._credentials = credentials
153
+ boto3_session = boto3.Session(botocore_session=botocore_session)
154
+ return boto3_session.client("s3", region_name=region)
155
+
156
+
157
+ def setup_aws_cli_profile(
158
+ api_url: str,
159
+ token: str,
160
+ region: str,
161
+ profile_name: str,
162
+ ):
163
+ """Set up an AWS CLI credential_process profile as a bonus.
164
+
165
+ Creates a credential helper script and configures AWS CLI to use it.
166
+ If AWS CLI is not installed, this is silently skipped.
167
+ """
168
+ # Check if AWS CLI is available
169
+ try:
170
+ subprocess.run(
171
+ ["aws", "--version"],
172
+ capture_output=True,
173
+ check=True,
174
+ )
175
+ except (FileNotFoundError, subprocess.CalledProcessError):
176
+ return False
177
+
178
+ config_dir = Path.home() / ".evercoast"
179
+ config_dir.mkdir(parents=True, exist_ok=True)
180
+
181
+ if platform.system() == "Windows":
182
+ helper_path = config_dir / f"{profile_name}-credential-helper.ps1"
183
+ helper_content = _generate_powershell_helper(api_url, token)
184
+ helper_path.write_text(helper_content, encoding="utf-8")
185
+ credential_process = f'powershell.exe -NoProfile -File "{helper_path}"'
186
+ else:
187
+ helper_path = config_dir / f"{profile_name}-credential-helper.sh"
188
+ helper_content = _generate_bash_helper(api_url, token)
189
+ helper_path.write_text(helper_content, encoding="utf-8")
190
+ helper_path.chmod(0o700)
191
+ credential_process = str(helper_path)
192
+
193
+ subprocess.run(
194
+ ["aws", "configure", "set", "credential_process", credential_process,
195
+ "--profile", profile_name],
196
+ capture_output=True,
197
+ )
198
+ subprocess.run(
199
+ ["aws", "configure", "set", "region", region,
200
+ "--profile", profile_name],
201
+ capture_output=True,
202
+ )
203
+ return True
204
+
205
+
206
+ def _generate_bash_helper(api_url: str, token: str) -> str:
207
+ """Generate a bash credential helper script."""
208
+ return f"""#!/usr/bin/env bash
209
+ # Auto-generated by evercoast CLI
210
+ # Called automatically by AWS CLI to get fresh credentials.
211
+ RESPONSE=$(curl -s -X POST "{api_url}/s3-credentials/" \\
212
+ -H "Authorization: Token {token}" \\
213
+ -H "EC-Agent: CB" \\
214
+ -H "Content-Type: application/json")
215
+
216
+ ERROR=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('error',''))" 2>/dev/null)
217
+ if [ -n "$ERROR" ]; then
218
+ echo "Error: $ERROR" >&2
219
+ echo "Run 'evercoast login' to re-authenticate." >&2
220
+ exit 1
221
+ fi
222
+
223
+ echo "$RESPONSE" | python3 -c "
224
+ import sys, json
225
+ d = json.load(sys.stdin)
226
+ print(json.dumps({{
227
+ 'Version': 1,
228
+ 'AccessKeyId': d['access_key_id'],
229
+ 'SecretAccessKey': d['secret_access_key'],
230
+ 'SessionToken': d['session_token'],
231
+ 'Expiration': d['expiration']
232
+ }}))
233
+ "
234
+ """
235
+
236
+
237
+ def _generate_powershell_helper(api_url: str, token: str) -> str:
238
+ """Generate a PowerShell credential helper script."""
239
+ return f"""# Auto-generated by evercoast CLI
240
+ $ErrorActionPreference = "Stop"
241
+ try {{
242
+ $Headers = @{{
243
+ "Authorization" = "Token {token}"
244
+ "EC-Agent" = "CB"
245
+ "Content-Type" = "application/json"
246
+ }}
247
+ $Response = Invoke-RestMethod -Uri "{api_url}/s3-credentials/" `
248
+ -Method Post `
249
+ -ContentType "application/json" `
250
+ -Headers $Headers `
251
+ -ErrorAction Stop
252
+ @{{
253
+ Version = 1
254
+ AccessKeyId = $Response.access_key_id
255
+ SecretAccessKey = $Response.secret_access_key
256
+ SessionToken = $Response.session_token
257
+ Expiration = $Response.expiration
258
+ }} | ConvertTo-Json
259
+ }}
260
+ catch {{
261
+ Write-Error "Error refreshing credentials. Run 'evercoast login' to re-authenticate."
262
+ exit 1
263
+ }}
264
+ """
@@ -0,0 +1,151 @@
1
+ """Config file management for Evercoast CLI.
2
+
3
+ Stores authentication tokens, bucket info, and user preferences
4
+ in ~/.evercoast/config.toml with per-profile sections.
5
+ """
6
+
7
+ import os
8
+ import platform
9
+ import re
10
+ import stat
11
+ from pathlib import Path
12
+ from typing import Any, Dict, Optional
13
+
14
+
15
+ # API URLs
16
+ PRODUCTION_API_URL = "https://api.cloudbreak.evercoast.com/api/v1"
17
+ STAGING_API_URL = "https://api.staging.cloudbreak.evercoast.com/api/v1"
18
+
19
+ DEFAULT_PROFILE = "default"
20
+ STAGING_PROFILE = "staging"
21
+
22
+
23
+ def get_config_dir() -> Path:
24
+ """Return the config directory path (~/.evercoast/)."""
25
+ return Path.home() / ".evercoast"
26
+
27
+
28
+ def get_config_path() -> Path:
29
+ """Return the config file path (~/.evercoast/config.toml)."""
30
+ return get_config_dir() / "config.toml"
31
+
32
+
33
+ def _parse_toml(text: str) -> Dict[str, Dict[str, str]]:
34
+ """Parse a simple TOML file into nested dicts.
35
+
36
+ Only supports [section] headers and key = "value" pairs.
37
+ No nested tables, arrays, or complex types.
38
+ """
39
+ result: Dict[str, Dict[str, str]] = {}
40
+ current_section = None
41
+
42
+ for line in text.splitlines():
43
+ line = line.strip()
44
+ if not line or line.startswith("#"):
45
+ continue
46
+
47
+ # Section header
48
+ section_match = re.match(r"^\[([^\]]+)\]$", line)
49
+ if section_match:
50
+ current_section = section_match.group(1).strip()
51
+ result[current_section] = {}
52
+ continue
53
+
54
+ # Key = value pair
55
+ kv_match = re.match(r'^(\w+)\s*=\s*"((?:[^"\\]|\\.)*)"$', line)
56
+ if kv_match and current_section is not None:
57
+ key = kv_match.group(1)
58
+ value = kv_match.group(2).replace('\\"', '"').replace("\\\\", "\\")
59
+ result[current_section][key] = value
60
+
61
+ return result
62
+
63
+
64
+ def _serialize_toml(data: Dict[str, Dict[str, str]]) -> str:
65
+ """Serialize nested dicts to simple TOML format."""
66
+ lines = []
67
+ for i, (section, values) in enumerate(data.items()):
68
+ if i > 0:
69
+ lines.append("")
70
+ lines.append(f"[{section}]")
71
+ for key, value in values.items():
72
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
73
+ lines.append(f'{key} = "{escaped}"')
74
+ lines.append("") # trailing newline
75
+ return "\n".join(lines)
76
+
77
+
78
+ class Config:
79
+ """Manages the Evercoast CLI config file."""
80
+
81
+ def __init__(self, config_path: Optional[Path] = None):
82
+ self.path = config_path or get_config_path()
83
+ self._data: Dict[str, Dict[str, str]] = {}
84
+ self._load()
85
+
86
+ def _load(self):
87
+ """Load config from disk if it exists."""
88
+ if self.path.exists():
89
+ text = self.path.read_text(encoding="utf-8")
90
+ self._data = _parse_toml(text)
91
+
92
+ def _save(self):
93
+ """Write config to disk with restricted permissions."""
94
+ self.path.parent.mkdir(parents=True, exist_ok=True)
95
+ self.path.write_text(_serialize_toml(self._data), encoding="utf-8")
96
+ # Restrict permissions on Unix (owner read/write only)
97
+ if platform.system() != "Windows":
98
+ self.path.chmod(stat.S_IRUSR | stat.S_IWUSR)
99
+
100
+ def save_profile(
101
+ self,
102
+ profile: str,
103
+ api_url: str,
104
+ token: str,
105
+ bucket: str,
106
+ region: str,
107
+ upload_path: str = "client-uploads/",
108
+ ):
109
+ """Save a profile with all connection details."""
110
+ self._data[profile] = {
111
+ "api_url": api_url,
112
+ "token": token,
113
+ "bucket": bucket,
114
+ "region": region,
115
+ "upload_path": upload_path,
116
+ }
117
+ self._save()
118
+
119
+ def get_profile(self, profile: str) -> Optional[Dict[str, str]]:
120
+ """Get a profile's config, or None if it doesn't exist."""
121
+ return self._data.get(profile)
122
+
123
+ def has_profile(self, profile: str) -> bool:
124
+ """Check if a profile exists."""
125
+ return profile in self._data
126
+
127
+ def get_value(self, profile: str, key: str) -> Optional[str]:
128
+ """Get a single value from a profile."""
129
+ p = self._data.get(profile)
130
+ if p is None:
131
+ return None
132
+ return p.get(key)
133
+
134
+ def set_value(self, profile: str, key: str, value: str):
135
+ """Set a single value in a profile (profile must exist)."""
136
+ if profile not in self._data:
137
+ self._data[profile] = {}
138
+ self._data[profile][key] = value
139
+ self._save()
140
+
141
+ def get_profile_name(self, staging: bool) -> str:
142
+ """Return the profile name based on staging flag."""
143
+ return STAGING_PROFILE if staging else DEFAULT_PROFILE
144
+
145
+ def get_api_url(self, staging: bool) -> str:
146
+ """Return the API URL for the given environment."""
147
+ profile = self.get_profile_name(staging)
148
+ p = self.get_profile(profile)
149
+ if p and "api_url" in p:
150
+ return p["api_url"]
151
+ return STAGING_API_URL if staging else PRODUCTION_API_URL