chipfoundry-cli 0.1.1__tar.gz → 0.1.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: chipfoundry-cli
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: CLI tool to automate ChipFoundry project submission to SFTP server
5
5
  Home-page: https://chipfoundry.io
6
6
  License: Apache-2.0
@@ -18,6 +18,7 @@ Classifier: Programming Language :: Python :: 3.13
18
18
  Requires-Dist: click (>=8.0.0,<9)
19
19
  Requires-Dist: paramiko (>=3.0.0,<4)
20
20
  Requires-Dist: rich (>=13,<14)
21
+ Requires-Dist: toml (>=0.10,<1.0)
21
22
  Project-URL: Repository, https://github.com/chipfoundry/cf-cli
22
23
  Description-Content-Type: text/markdown
23
24
 
@@ -38,8 +39,8 @@ A command-line tool to automate the submission of ChipFoundry projects to the SF
38
39
  Install from PyPI:
39
40
 
40
41
  ```bash
41
- pip install cf-cli
42
- cf --help
42
+ pip install chipfoundry-cli
43
+ chipfoundry --help
43
44
  ```
44
45
 
45
46
  ---
@@ -86,39 +87,57 @@ my_project/
86
87
 
87
88
  ## Usage
88
89
 
89
- ### Basic Submission (Digital Project)
90
+ ### Configure User Credentials
90
91
 
91
92
  ```bash
92
- cf submit --project-root /path/to/my_project --sftp-username <your_chipfoundry_username>
93
+ chipfoundry config
93
94
  ```
95
+ - Prompts for your SFTP username and key path. Only needs to be run once per user/machine.
94
96
 
95
- ### With a Custom SSH Key
97
+ ### Initialize a New Project
96
98
 
97
99
  ```bash
98
- cf submit --project-root /path/to/my_project --sftp-username <your_chipfoundry_username> --sftp-key /path/to/id_rsa
100
+ chipfoundry init
99
101
  ```
102
+ - Prompts for project name, type (auto-detected from GDS file if present), and version.
103
+ - Creates `.cf/project.json` in the current directory.
104
+ - **Note:** The GDS hash is NOT generated at this step (see below).
100
105
 
101
- ### With Password Authentication
106
+ ### Push a Project (Upload)
102
107
 
103
108
  ```bash
104
- cf submit --project-root /path/to/my_project --sftp-username <your_chipfoundry_username> --sftp-password <your_password>
109
+ chipfoundry push
105
110
  ```
111
+ - Run from your project directory (with `.cf/project.json`).
112
+ - Collects files, updates the GDS hash, and uploads to SFTP.
106
113
 
107
- ### Dry Run (Preview what will be uploaded)
114
+ ### Pull Results
108
115
 
109
116
  ```bash
110
- cf submit --project-root /path/to/my_project --sftp-username <your_chipfoundry_username> --dry-run
117
+ chipfoundry pull
111
118
  ```
119
+ - Downloads results for the current project to a local directory.
112
120
 
113
- ### Override Project Name or ID
121
+ ### Check Status
114
122
 
115
123
  ```bash
116
- cf submit --project-root /path/to/my_project --sftp-username <your_chipfoundry_username> --project-name my_custom_name --project-id my_custom_id
124
+ chipfoundry status
117
125
  ```
126
+ - Shows all your projects and their input/output status on the SFTP server.
118
127
 
119
128
  ---
120
129
 
121
- ## What Happens When You Run `cf submit`?
130
+ ## How the GDS Hash Works
131
+
132
+ - The `user_project_wrapper_hash` in `.cf/project.json` is **automatically generated and updated during `push`**.
133
+ - The hash is calculated from the actual GDS file being uploaded.
134
+ - This ensures the hash always matches the file you are submitting.
135
+ - **You do not need to manage or update the hash manually.**
136
+ - The hash is NOT generated during `init` because the GDS file may not exist or may change before submission.
137
+
138
+ ---
139
+
140
+ ## What Happens When You Run `chipfoundry push`?
122
141
 
123
142
  1. **File Collection:**
124
143
  - The tool checks for the required GDS and Verilog files.
@@ -146,6 +165,8 @@ cf submit --project-root /path/to/my_project --sftp-username <your_chipfoundry_u
146
165
  - Check your network connection and credentials.
147
166
  - **Project type detection:**
148
167
  - Only one of the recognized GDS files should be present in your `gds/` directory.
168
+ - **ModuleNotFoundError: No module named 'toml':**
169
+ - This means your environment is missing the `toml` dependency. Upgrade `chipfoundry-cli` with `pip install --upgrade chipfoundry-cli`, or install `toml` manually with `pip install 'toml>=0.10,<1.0'`.
149
170
 
150
171
  ---
151
172
 
@@ -15,8 +15,8 @@ A command-line tool to automate the submission of ChipFoundry projects to the SF
15
15
  Install from PyPI:
16
16
 
17
17
  ```bash
18
- pip install cf-cli
19
- cf --help
18
+ pip install chipfoundry-cli
19
+ chipfoundry --help
20
20
  ```
21
21
 
22
22
  ---
@@ -63,39 +63,57 @@ my_project/
63
63
 
64
64
  ## Usage
65
65
 
66
- ### Basic Submission (Digital Project)
66
+ ### Configure User Credentials
67
67
 
68
68
  ```bash
69
- cf submit --project-root /path/to/my_project --sftp-username <your_chipfoundry_username>
69
+ chipfoundry config
70
70
  ```
71
+ - Prompts for your SFTP username and key path. Only needs to be run once per user/machine.
71
72
 
72
- ### With a Custom SSH Key
73
+ ### Initialize a New Project
73
74
 
74
75
  ```bash
75
- cf submit --project-root /path/to/my_project --sftp-username <your_chipfoundry_username> --sftp-key /path/to/id_rsa
76
+ chipfoundry init
76
77
  ```
78
+ - Prompts for project name, type (auto-detected from GDS file if present), and version.
79
+ - Creates `.cf/project.json` in the current directory.
80
+ - **Note:** The GDS hash is NOT generated at this step (see below).
77
81
 
78
- ### With Password Authentication
82
+ ### Push a Project (Upload)
79
83
 
80
84
  ```bash
81
- cf submit --project-root /path/to/my_project --sftp-username <your_chipfoundry_username> --sftp-password <your_password>
85
+ chipfoundry push
82
86
  ```
87
+ - Run from your project directory (with `.cf/project.json`).
88
+ - Collects files, updates the GDS hash, and uploads to SFTP.
83
89
 
84
- ### Dry Run (Preview what will be uploaded)
90
+ ### Pull Results
85
91
 
86
92
  ```bash
87
- cf submit --project-root /path/to/my_project --sftp-username <your_chipfoundry_username> --dry-run
93
+ chipfoundry pull
88
94
  ```
95
+ - Downloads results for the current project to a local directory.
89
96
 
90
- ### Override Project Name or ID
97
+ ### Check Status
91
98
 
92
99
  ```bash
93
- cf submit --project-root /path/to/my_project --sftp-username <your_chipfoundry_username> --project-name my_custom_name --project-id my_custom_id
100
+ chipfoundry status
94
101
  ```
102
+ - Shows all your projects and their input/output status on the SFTP server.
95
103
 
96
104
  ---
97
105
 
98
- ## What Happens When You Run `cf submit`?
106
+ ## How the GDS Hash Works
107
+
108
+ - The `user_project_wrapper_hash` in `.cf/project.json` is **automatically generated and updated during `push`**.
109
+ - The hash is calculated from the actual GDS file being uploaded.
110
+ - This ensures the hash always matches the file you are submitting.
111
+ - **You do not need to manage or update the hash manually.**
112
+ - The hash is NOT generated during `init` because the GDS file may not exist or may change before submission.
113
+
114
+ ---
115
+
116
+ ## What Happens When You Run `chipfoundry push`?
99
117
 
100
118
  1. **File Collection:**
101
119
  - The tool checks for the required GDS and Verilog files.
@@ -123,6 +141,8 @@ cf submit --project-root /path/to/my_project --sftp-username <your_chipfoundry_u
123
141
  - Check your network connection and credentials.
124
142
  - **Project type detection:**
125
143
  - Only one of the recognized GDS files should be present in your `gds/` directory.
144
+ - **ModuleNotFoundError: No module named 'toml':**
145
+ - This means your environment is missing the `toml` dependency. Upgrade `chipfoundry-cli` with `pip install --upgrade chipfoundry-cli`, or install `toml` manually with `pip install 'toml>=0.10,<1.0'`.
126
146
 
127
147
  ---
128
148
 
@@ -0,0 +1,454 @@
1
+ import click
2
+ import getpass
3
+ from chipfoundry_cli.utils import (
4
+ collect_project_files, ensure_cf_directory, update_or_create_project_json,
5
+ sftp_connect, upload_with_progress, sftp_ensure_dirs,
6
+ get_config_path, load_user_config, save_user_config
7
+ )
8
+ import os
9
+ from pathlib import Path
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+ from rich.text import Text
13
+ import importlib.metadata
14
+ from rich.table import Table
15
+ from rich.progress import Progress, BarColumn, TextColumn, TimeElapsedColumn, TaskProgressColumn
16
+ import json
17
+
18
+ DEFAULT_SSH_KEY = os.path.expanduser('~/.ssh/id_rsa')
19
+ DEFAULT_SFTP_HOST = 'sftp.chipfoundry.io'
20
+
21
+ GDS_TYPE_MAP = {
22
+ 'user_project_wrapper.gds': 'digital',
23
+ 'user_analog_project_wrapper.gds': 'analog',
24
+ 'openframe_project_wrapper.gds': 'openframe',
25
+ }
26
+
27
+ console = Console()
28
+
29
+ def get_project_json_from_cwd():
30
+ cf_path = Path(os.getcwd()) / '.cf' / 'project.json'
31
+ if cf_path.exists():
32
+ with open(cf_path) as f:
33
+ data = json.load(f)
34
+ project_name = data.get('project', {}).get('name')
35
+ return str(Path(os.getcwd())), project_name
36
+ return None, None
37
+
38
+ @click.group(help="ChipFoundry CLI: Automate project submission and management.")
39
+ @click.version_option(importlib.metadata.version("chipfoundry-cli"), "-v", "--version", message="%(version)s")
40
+ def main():
41
+ pass
42
+
43
+ @main.command('config')
44
+ def config_cmd():
45
+ """Configure user-level SFTP credentials (username and key)."""
46
+ console.print("[bold cyan]ChipFoundry CLI User Configuration[/bold cyan]")
47
+ username = console.input("Enter your ChipFoundry SFTP username: ").strip()
48
+ key_path = console.input("Enter path to your SFTP private key (leave blank for ~/.ssh/id_rsa): ").strip()
49
+ if not key_path:
50
+ key_path = os.path.expanduser('~/.ssh/id_rsa')
51
+ config = {
52
+ "sftp_username": username,
53
+ "sftp_key": key_path,
54
+ }
55
+ save_user_config(config)
56
+ console.print(f"[green]Configuration saved to {get_config_path()}[/green]")
57
+
58
+ @main.command('init')
59
+ @click.option('--project-root', required=False, type=click.Path(file_okay=False), help='Directory to create the project in (defaults to current directory).')
60
+ def init(project_root):
61
+ """Initialize a new ChipFoundry project (.cf/project.json) in the given directory."""
62
+ if not project_root:
63
+ project_root = os.getcwd()
64
+ cf_dir = Path(project_root) / '.cf'
65
+ cf_dir.mkdir(parents=True, exist_ok=True)
66
+ project_json_path = cf_dir / 'project.json'
67
+ if project_json_path.exists():
68
+ overwrite = console.input(f"[yellow]project.json already exists at {project_json_path}. Overwrite? (y/N): [/yellow]").strip().lower()
69
+ if overwrite != 'y':
70
+ console.print("[red]Aborted project initialization.[/red]")
71
+ return
72
+ # Get username from user config
73
+ config = load_user_config()
74
+ username = config.get("sftp_username")
75
+ if not username:
76
+ console.print("[bold red]No SFTP username found in user config. Please run 'chipfoundry config' first.[/bold red]")
77
+ raise click.Abort()
78
+ # Auto-detect project type from GDS file name
79
+ gds_dir = Path(project_root) / 'gds'
80
+ gds_type = None
81
+ gds_type_map = {
82
+ 'user_project_wrapper.gds': 'digital',
83
+ 'user_analog_project_wrapper.gds': 'analog',
84
+ 'openframe_project_wrapper.gds': 'openframe',
85
+ }
86
+ for gds_name, gtype in gds_type_map.items():
87
+ if (gds_dir / gds_name).exists():
88
+ gds_type = gtype
89
+ break
90
+ name = console.input("Project name: ").strip()
91
+ # Suggest project type if detected
92
+ if gds_type:
93
+ project_type = console.input(f"Project type (digital/analog/openframe) [default: {gds_type}]: ").strip() or gds_type
94
+ else:
95
+ project_type = console.input("Project type (digital/analog/openframe): ").strip()
96
+ version = console.input("Version (default 1.0.0): ").strip() or "1.0.0"
97
+ # No hash yet, will be filled by push
98
+ data = {
99
+ "project": {
100
+ "name": name,
101
+ "type": project_type,
102
+ "user": username,
103
+ "version": version,
104
+ "user_project_wrapper_hash": ""
105
+ }
106
+ }
107
+ with open(project_json_path, 'w') as f:
108
+ json.dump(data, f, indent=2)
109
+ console.print(f"[green]Initialized project at {project_json_path}[/green]")
110
+
111
+ @main.command('push')
112
+ @click.option('--project-root', required=False, type=click.Path(exists=True, file_okay=False), help='Path to the local ChipFoundry project directory (defaults to current directory if .cf/project.json exists).')
113
+ @click.option('--sftp-host', default=DEFAULT_SFTP_HOST, show_default=True, help='SFTP server hostname.')
114
+ @click.option('--sftp-username', required=False, help='SFTP username (defaults to config).')
115
+ @click.option('--sftp-key', type=click.Path(exists=True, dir_okay=False), help='Path to SFTP private key file (defaults to config).', default=None, show_default=False)
116
+ @click.option('--sftp-password', help='SFTP password. If not provided, will prompt securely.', default=None)
117
+ @click.option('--project-id', help='Project ID (e.g., "user123_proj456"). Overrides project.json if exists.')
118
+ @click.option('--project-name', help='Project name (e.g., "my_project"). Overrides project.json if exists.')
119
+ @click.option('--project-type', help='Project type (auto-detected if not provided).', default=None)
120
+ @click.option('--force-overwrite', is_flag=True, help='Overwrite existing files on SFTP without prompting.')
121
+ @click.option('--dry-run', is_flag=True, help='Preview actions without uploading files.')
122
+ def push(project_root, sftp_host, sftp_username, sftp_key, sftp_password, project_id, project_name, project_type, force_overwrite, dry_run):
123
+ """Upload your project files to the ChipFoundry SFTP server."""
124
+ # If .cf/project.json exists in cwd, use it as default project_root and project_name
125
+ cwd_root, cwd_project_name = get_project_json_from_cwd()
126
+ if not project_root and cwd_root:
127
+ project_root = cwd_root
128
+ if not project_name and cwd_project_name:
129
+ project_name = cwd_project_name
130
+ if not project_root:
131
+ console.print("[bold red]No project root specified and no .cf/project.json found in current directory. Please provide --project-root.[/bold red]")
132
+ raise click.Abort()
133
+ # Load user config for defaults
134
+ config = load_user_config()
135
+ if not sftp_username:
136
+ sftp_username = config.get("sftp_username")
137
+ if not sftp_username:
138
+ console.print("[bold red]No SFTP username provided and not found in config. Please run 'chipfoundry init' or provide --sftp-username.[/bold red]")
139
+ raise click.Abort()
140
+ if not sftp_key:
141
+ sftp_key = config.get("sftp_key")
142
+ # Determine which authentication method to use
143
+ key_path = sftp_key
144
+ password = sftp_password
145
+ if not key_path and not password:
146
+ if os.path.exists(DEFAULT_SSH_KEY):
147
+ key_path = DEFAULT_SSH_KEY
148
+ console.print(f"[INFO] Using default SSH key: {DEFAULT_SSH_KEY}", style="bold cyan")
149
+ else:
150
+ console.print("[WARN] No SFTP key or password provided, and no default key found at ~/.ssh/id_rsa.", style="bold yellow")
151
+ auth_method = click.prompt("Choose authentication method (key/password)", type=click.Choice(['key', 'password']), show_choices=True)
152
+ if auth_method == 'key':
153
+ key_path = click.prompt("Enter path to SFTP private key", type=click.Path(exists=True, dir_okay=False))
154
+ else:
155
+ password = click.prompt("SFTP Password", hide_input=True)
156
+ elif key_path and password:
157
+ console.print("[ERROR] Options --sftp-password and --sftp-key are mutually exclusive.", style="bold red")
158
+ raise click.UsageError("Options --sftp-password and --sftp-key are mutually exclusive.")
159
+ elif not key_path and password:
160
+ pass # password provided
161
+ elif key_path and not password:
162
+ if not os.path.exists(key_path):
163
+ console.print(f"[ERROR] SFTP key file not found: {key_path}", style="bold red")
164
+ raise click.UsageError(f"SFTP key file not found: {key_path}")
165
+
166
+ console.print(f"[INFO] Collecting project files from: {project_root}", style="bold cyan")
167
+ try:
168
+ collected = collect_project_files(project_root)
169
+ for rel_path, abs_path in collected.items():
170
+ if abs_path:
171
+ console.print(f"[OK] Found: {rel_path} -> {abs_path}", style="green")
172
+ else:
173
+ console.print(f"[INFO] Optional file not found: {rel_path}", style="yellow")
174
+ except FileNotFoundError as e:
175
+ console.print(f"[ERROR] {e}", style="bold red")
176
+ raise click.Abort()
177
+
178
+ # Auto-detect project type from GDS file name if not provided
179
+ gds_dir = Path(project_root) / 'gds'
180
+ found_types = []
181
+ gds_file_path = None
182
+ for gds_name, gds_type in GDS_TYPE_MAP.items():
183
+ candidate = gds_dir / gds_name
184
+ if candidate.exists():
185
+ found_types.append(gds_type)
186
+ gds_file_path = str(candidate)
187
+ if project_type:
188
+ detected_type = project_type
189
+ else:
190
+ if len(found_types) == 0:
191
+ console.print("[ERROR] No recognized GDS file found for project type detection.", style="bold red")
192
+ raise click.Abort()
193
+ elif len(found_types) > 1:
194
+ console.print(f"[ERROR] Multiple GDS types found: {found_types}. Only one project type is allowed per project.", style="bold red")
195
+ raise click.Abort()
196
+ else:
197
+ detected_type = found_types[0]
198
+ console.print(f"[INFO] Detected project type: {detected_type}", style="bold cyan")
199
+ # Use the detected GDS file for upload and hash
200
+ if gds_file_path:
201
+ collected['gds/user_project_wrapper.gds'] = gds_file_path
202
+ # Prepare CLI overrides for project.json
203
+ cli_overrides = {
204
+ "project_id": project_id,
205
+ "project_name": project_name,
206
+ "project_type": detected_type,
207
+ "sftp_username": sftp_username,
208
+ }
209
+ cf_dir = ensure_cf_directory(project_root)
210
+ console.print(f"[INFO] Generating/updating project.json in {cf_dir}", style="bold cyan")
211
+ project_json_path = update_or_create_project_json(
212
+ cf_dir=str(cf_dir),
213
+ gds_path=collected["gds/user_project_wrapper.gds"],
214
+ cli_overrides=cli_overrides,
215
+ existing_json_path=collected.get(".cf/project.json")
216
+ )
217
+ console.print(f"[OK] project.json ready: {project_json_path}", style="green")
218
+
219
+ # SFTP upload or dry-run
220
+ final_project_name = project_name or (
221
+ cli_overrides.get("project_name") or Path(project_root).name
222
+ )
223
+ sftp_base = f"incoming/projects/{final_project_name}"
224
+ upload_map = {
225
+ ".cf/project.json": project_json_path,
226
+ "gds/user_project_wrapper.gds": collected["gds/user_project_wrapper.gds"],
227
+ "verilog/rtl/user_defines.v": collected["verilog/rtl/user_defines.v"],
228
+ }
229
+ if dry_run:
230
+ console.print("[DRY-RUN] The following files would be uploaded:", style="bold magenta")
231
+ for rel_path, local_path in upload_map.items():
232
+ if local_path:
233
+ remote_path = os.path.join(sftp_base, rel_path)
234
+ console.print(f" {local_path} -> {remote_path}", style="magenta")
235
+ console.print("[DRY-RUN] No files were uploaded.", style="bold magenta")
236
+ return
237
+
238
+ console.print(f"[INFO] Connecting to SFTP: {sftp_host} as {sftp_username}", style="bold cyan")
239
+ transport = None
240
+ try:
241
+ sftp, transport = sftp_connect(
242
+ host=sftp_host,
243
+ username=sftp_username,
244
+ password=password,
245
+ key_path=key_path
246
+ )
247
+ # Ensure the project directory exists before uploading
248
+ sftp_project_dir = f"incoming/projects/{final_project_name}"
249
+ sftp_ensure_dirs(sftp, sftp_project_dir)
250
+ except Exception as e:
251
+ console.print(f"[ERROR] Failed to connect to SFTP: {e}", style="bold red")
252
+ raise click.Abort()
253
+ try:
254
+ for rel_path, local_path in upload_map.items():
255
+ if local_path:
256
+ remote_path = os.path.join(sftp_base, rel_path)
257
+ upload_with_progress(
258
+ sftp,
259
+ local_path=local_path,
260
+ remote_path=remote_path,
261
+ force_overwrite=force_overwrite
262
+ )
263
+ console.print(f"[SUCCESS] All files uploaded to {sftp_base}", style="bold green")
264
+ except Exception as e:
265
+ console.print(f"[ERROR] SFTP upload failed: {e}", style="bold red")
266
+ raise click.Abort()
267
+ finally:
268
+ if transport:
269
+ sftp.close()
270
+ transport.close()
271
+
272
+ @main.command('pull')
273
+ @click.option('--project-name', required=False, help='Project name to pull results for (defaults to value in .cf/project.json if present).')
274
+ @click.option('--output-dir', required=False, type=click.Path(file_okay=False), help='(Ignored) Local directory to save results (now always sftp-output/<project_name>).')
275
+ @click.option('--sftp-host', default=DEFAULT_SFTP_HOST, show_default=True, help='SFTP server hostname.')
276
+ @click.option('--sftp-username', required=False, help='SFTP username (defaults to config).')
277
+ @click.option('--sftp-key', type=click.Path(exists=True, dir_okay=False), help='Path to SFTP private key file (defaults to config).', default=None, show_default=False)
278
+ @click.option('--sftp-password', help='SFTP password. If not provided, will prompt securely.', default=None)
279
+ def pull(project_name, output_dir, sftp_host, sftp_username, sftp_key, sftp_password):
280
+ """Download results/artifacts from SFTP output dir to local sftp-output/<project_name>."""
281
+ # If .cf/project.json exists in cwd, use its project name as default
282
+ _, cwd_project_name = get_project_json_from_cwd()
283
+ if not project_name and cwd_project_name:
284
+ project_name = cwd_project_name
285
+ if not project_name:
286
+ console.print("[bold red]No project name specified and no .cf/project.json found in current directory. Please provide --project-name.[/bold red]")
287
+ raise click.Abort()
288
+ config = load_user_config()
289
+ if not sftp_username:
290
+ sftp_username = config.get("sftp_username")
291
+ if not sftp_username:
292
+ console.print("[bold red]No SFTP username provided and not found in config. Please run 'chipfoundry config' or provide --sftp-username.[/bold red]")
293
+ raise click.Abort()
294
+ if not sftp_key:
295
+ sftp_key = config.get("sftp_key")
296
+ key_path = sftp_key
297
+ password = sftp_password
298
+ if not key_path and not password:
299
+ if os.path.exists(DEFAULT_SSH_KEY):
300
+ key_path = DEFAULT_SSH_KEY
301
+ console.print(f"[INFO] Using default SSH key: {DEFAULT_SSH_KEY}", style="bold cyan")
302
+ else:
303
+ console.print("[WARN] No SFTP key or password provided, and no default key found at ~/.ssh/id_rsa.", style="bold yellow")
304
+ auth_method = click.prompt("Choose authentication method (key/password)", type=click.Choice(['key', 'password']), show_choices=True)
305
+ if auth_method == 'key':
306
+ key_path = click.prompt("Enter path to SFTP private key", type=click.Path(exists=True, dir_okay=False))
307
+ else:
308
+ password = click.prompt("SFTP Password", hide_input=True)
309
+ elif key_path and password:
310
+ console.print("[ERROR] Options --sftp-password and --sftp-key are mutually exclusive.", style="bold red")
311
+ raise click.UsageError("Options --sftp-password and --sftp-key are mutually exclusive.")
312
+ elif not key_path and password:
313
+ pass # password provided
314
+ elif key_path and not password:
315
+ if not os.path.exists(key_path):
316
+ console.print(f"[ERROR] SFTP key file not found: {key_path}", style="bold red")
317
+ raise click.UsageError(f"SFTP key file not found: {key_path}")
318
+
319
+ console.print(f"[INFO] Connecting to SFTP: {sftp_host} as {sftp_username}", style="bold cyan")
320
+ transport = None
321
+ try:
322
+ sftp, transport = sftp_connect(
323
+ host=sftp_host,
324
+ username=sftp_username,
325
+ password=password,
326
+ key_path=key_path
327
+ )
328
+ except Exception as e:
329
+ console.print(f"[ERROR] Failed to connect to SFTP: {e}", style="bold red")
330
+ raise click.Abort()
331
+ try:
332
+ remote_dir = f"outgoing/results/{project_name}"
333
+ output_dir = os.path.join(os.getcwd(), "sftp-output", project_name)
334
+ os.makedirs(output_dir, exist_ok=True)
335
+ try:
336
+ files = sftp.listdir(remote_dir)
337
+ except Exception:
338
+ console.print(f"[yellow]No results found for project '{project_name}' on SFTP server.[/yellow]")
339
+ return
340
+ if not files:
341
+ console.print(f"[yellow]No files to download for project '{project_name}'.[/yellow]")
342
+ return
343
+ with Progress(
344
+ TextColumn("[progress.description]{task.description}"),
345
+ BarColumn(),
346
+ TaskProgressColumn(),
347
+ TextColumn("{task.percentage:>3.0f}%"),
348
+ TextColumn("•"),
349
+ TextColumn("{task.completed}/{task.total} bytes"),
350
+ TimeElapsedColumn(),
351
+ ) as progress:
352
+ for fname in files:
353
+ remote_path = f"{remote_dir}/{fname}"
354
+ local_path = os.path.join(output_dir, fname)
355
+ try:
356
+ file_size = sftp.stat(remote_path).st_size
357
+ task = progress.add_task(f"Downloading {fname}", total=file_size)
358
+ with open(local_path, "wb") as f:
359
+ def callback(bytes_transferred, total=file_size):
360
+ progress.update(task, completed=bytes_transferred)
361
+ sftp.getfo(remote_path, f, callback=callback)
362
+ progress.update(task, completed=file_size)
363
+ except Exception as e:
364
+ console.print(f"[red]Failed to download {fname}: {e}[/red]")
365
+ console.print(f"[green]All files downloaded to {output_dir}[/green]")
366
+ finally:
367
+ if transport:
368
+ sftp.close()
369
+ transport.close()
370
+
371
+ @main.command('status')
372
+ @click.option('--sftp-host', default=DEFAULT_SFTP_HOST, show_default=True, help='SFTP server hostname.')
373
+ @click.option('--sftp-username', required=False, help='SFTP username (defaults to config).')
374
+ @click.option('--sftp-key', type=click.Path(exists=True, dir_okay=False), help='Path to SFTP private key file (defaults to config).', default=None, show_default=False)
375
+ @click.option('--sftp-password', help='SFTP password. If not provided, will prompt securely.', default=None)
376
+ def status(sftp_host, sftp_username, sftp_key, sftp_password):
377
+ """Show all projects and outputs for the user on the SFTP server."""
378
+ config = load_user_config()
379
+ if not sftp_username:
380
+ sftp_username = config.get("sftp_username")
381
+ if not sftp_username:
382
+ console.print("[bold red]No SFTP username provided and not found in config. Please run 'chipfoundry init' or provide --sftp-username.[/bold red]")
383
+ raise click.Abort()
384
+ if not sftp_key:
385
+ sftp_key = config.get("sftp_key")
386
+ key_path = sftp_key
387
+ password = sftp_password
388
+ if not key_path and not password:
389
+ if os.path.exists(DEFAULT_SSH_KEY):
390
+ key_path = DEFAULT_SSH_KEY
391
+ console.print(f"[INFO] Using default SSH key: {DEFAULT_SSH_KEY}", style="bold cyan")
392
+ else:
393
+ console.print("[WARN] No SFTP key or password provided, and no default key found at ~/.ssh/id_rsa.", style="bold yellow")
394
+ auth_method = click.prompt("Choose authentication method (key/password)", type=click.Choice(['key', 'password']), show_choices=True)
395
+ if auth_method == 'key':
396
+ key_path = click.prompt("Enter path to SFTP private key", type=click.Path(exists=True, dir_okay=False))
397
+ else:
398
+ password = click.prompt("SFTP Password", hide_input=True)
399
+ elif key_path and password:
400
+ console.print("[ERROR] Options --sftp-password and --sftp-key are mutually exclusive.", style="bold red")
401
+ raise click.UsageError("Options --sftp-password and --sftp-key are mutually exclusive.")
402
+ elif not key_path and password:
403
+ pass # password provided
404
+ elif key_path and not password:
405
+ if not os.path.exists(key_path):
406
+ console.print(f"[ERROR] SFTP key file not found: {key_path}", style="bold red")
407
+ raise click.UsageError(f"SFTP key file not found: {key_path}")
408
+
409
+ console.print(f"[INFO] Connecting to SFTP: {sftp_host} as {sftp_username}", style="bold cyan")
410
+ transport = None
411
+ try:
412
+ sftp, transport = sftp_connect(
413
+ host=sftp_host,
414
+ username=sftp_username,
415
+ password=password,
416
+ key_path=key_path
417
+ )
418
+ except Exception as e:
419
+ console.print(f"[ERROR] Failed to connect to SFTP: {e}", style="bold red")
420
+ raise click.Abort()
421
+ try:
422
+ # List projects in incoming/projects/ and outgoing/results/
423
+ incoming_projects_dir = f"incoming/projects"
424
+ outgoing_results_dir = f"outgoing/results"
425
+ projects = []
426
+ results = []
427
+ try:
428
+ projects = sftp.listdir(incoming_projects_dir)
429
+ except Exception:
430
+ pass
431
+ try:
432
+ results = sftp.listdir(outgoing_results_dir)
433
+ except Exception:
434
+ pass
435
+ table = Table(title=f"SFTP Status for {sftp_username}")
436
+ table.add_column("Project Name", style="cyan", no_wrap=True)
437
+ table.add_column("Has Input", style="yellow")
438
+ table.add_column("Has Output", style="green")
439
+ all_projects = set(projects) | set(results)
440
+ for proj in sorted(all_projects):
441
+ has_input = "Yes" if proj in projects else "No"
442
+ has_output = "Yes" if proj in results else "No"
443
+ table.add_row(proj, has_input, has_output)
444
+ if all_projects:
445
+ console.print(table)
446
+ else:
447
+ console.print("[yellow]No projects or results found on SFTP server.[/yellow]")
448
+ finally:
449
+ if transport:
450
+ sftp.close()
451
+ transport.close()
452
+
453
+ if __name__ == "__main__":
454
+ main()
@@ -6,6 +6,7 @@ import json
6
6
  import hashlib
7
7
  import paramiko
8
8
  from rich.progress import Progress, BarColumn, TextColumn, TimeElapsedColumn, TaskProgressColumn
9
+ import toml
9
10
 
10
11
  REQUIRED_FILES = {
11
12
  ".cf/project.json": False, # Optional, may not exist
@@ -177,4 +178,19 @@ def upload_with_progress(sftp, local_path, remote_path, force_overwrite=False):
177
178
  progress.update(task, completed=bytes_transferred)
178
179
  result = sftp_upload_file(sftp, local_path, remote_path, force_overwrite, progress_cb=progress_cb)
179
180
  progress.update(task, completed=file_size)
180
- return result
181
+ return result
182
+
183
+ def get_config_path() -> Path:
184
+ return Path.home() / ".chipfoundry-cli" / "config.toml"
185
+
186
+ def load_user_config() -> dict:
187
+ config_path = get_config_path()
188
+ if config_path.exists():
189
+ return toml.load(config_path)
190
+ return {}
191
+
192
+ def save_user_config(config: dict):
193
+ config_path = get_config_path()
194
+ config_path.parent.mkdir(parents=True, exist_ok=True)
195
+ with open(config_path, 'w') as f:
196
+ toml.dump(config, f)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "chipfoundry-cli"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  description = "CLI tool to automate ChipFoundry project submission to SFTP server"
5
5
  authors = ["ChipFoundry <marwan.abbas@chipfoundry.io>"]
6
6
  readme = "README.md"
@@ -14,6 +14,7 @@ python = ">=3.8.0"
14
14
  click = ">=8.0.0,<9"
15
15
  rich = ">=13,<14"
16
16
  paramiko = ">=3.0.0,<4"
17
+ toml = ">=0.10,<1.0"
17
18
 
18
19
  [tool.poetry.dev-dependencies]
19
20
  wheel = "*"
@@ -22,6 +23,7 @@ flake8 = ">=4"
22
23
 
23
24
  [tool.poetry.scripts]
24
25
  chipfoundry = "chipfoundry_cli.main:main"
26
+ cf = "chipfoundry_cli.main:main"
25
27
 
26
28
  [build-system]
27
29
  requires = ["poetry-core>=1.0.0"]
@@ -1,173 +0,0 @@
1
- import click
2
- import getpass
3
- from chipfoundry_cli.utils import (
4
- collect_project_files, ensure_cf_directory, update_or_create_project_json,
5
- sftp_connect, upload_with_progress, sftp_ensure_dirs
6
- )
7
- import os
8
- from pathlib import Path
9
- from rich.console import Console
10
- from rich.panel import Panel
11
- from rich.text import Text
12
-
13
- DEFAULT_SSH_KEY = os.path.expanduser('~/.ssh/id_rsa')
14
- DEFAULT_SFTP_HOST = 'sftp.chipfoundry.io'
15
-
16
- GDS_TYPE_MAP = {
17
- 'user_project_wrapper.gds': 'digital',
18
- 'user_analog_project_wrapper.gds': 'analog',
19
- 'openframe_project_wrapper.gds': 'openframe',
20
- }
21
-
22
- console = Console()
23
-
24
- @click.group(help="ChipFoundry CLI: Automate project submission and management.")
25
- def main():
26
- pass
27
-
28
- @main.command('submit')
29
- @click.option('--project-root', required=True, type=click.Path(exists=True, file_okay=False), help='Path to the local ChipFoundry project directory.')
30
- @click.option('--sftp-host', default=DEFAULT_SFTP_HOST, show_default=True, help='SFTP server hostname.')
31
- @click.option('--sftp-username', required=True, help='SFTP username.')
32
- @click.option('--sftp-key', type=click.Path(exists=True, dir_okay=False), help='Path to SFTP private key file. Defaults to ~/.ssh/id_rsa if it exists.', default=None, show_default=False)
33
- @click.option('--sftp-password', help='SFTP password. If not provided, will prompt securely.', default=None)
34
- @click.option('--project-id', help='Project ID (e.g., "user123_proj456"). Overrides project.json if exists.')
35
- @click.option('--project-name', help='Project name (e.g., "my_project"). Overrides project.json if exists.')
36
- @click.option('--project-type', help='Project type (auto-detected if not provided).', default=None)
37
- @click.option('--force-overwrite', is_flag=True, help='Overwrite existing files on SFTP without prompting.')
38
- @click.option('--dry-run', is_flag=True, help='Preview actions without uploading files.')
39
- def submit(project_root, sftp_host, sftp_username, sftp_key, sftp_password, project_id, project_name, project_type, force_overwrite, dry_run):
40
- """Submit a project to the SFTP server."""
41
- # Determine which authentication method to use
42
- key_path = sftp_key
43
- password = sftp_password
44
- # If neither provided, try default key
45
- if not key_path and not password:
46
- if os.path.exists(DEFAULT_SSH_KEY):
47
- key_path = DEFAULT_SSH_KEY
48
- console.print(f"[INFO] Using default SSH key: {DEFAULT_SSH_KEY}", style="bold cyan")
49
- else:
50
- console.print("[WARN] No SFTP key or password provided, and no default key found at ~/.ssh/id_rsa.", style="bold yellow")
51
- auth_method = click.prompt("Choose authentication method (key/password)", type=click.Choice(['key', 'password']), show_choices=True)
52
- if auth_method == 'key':
53
- key_path = click.prompt("Enter path to SFTP private key", type=click.Path(exists=True, dir_okay=False))
54
- else:
55
- password = click.prompt("SFTP Password", hide_input=True)
56
- elif key_path and password:
57
- console.print("[ERROR] Options --sftp-password and --sftp-key are mutually exclusive.", style="bold red")
58
- raise click.UsageError("Options --sftp-password and --sftp-key are mutually exclusive.")
59
- elif not key_path and password:
60
- pass # password provided
61
- elif key_path and not password:
62
- if not os.path.exists(key_path):
63
- console.print(f"[ERROR] SFTP key file not found: {key_path}", style="bold red")
64
- raise click.UsageError(f"SFTP key file not found: {key_path}")
65
-
66
- console.print(f"[INFO] Collecting project files from: {project_root}", style="bold cyan")
67
- try:
68
- collected = collect_project_files(project_root)
69
- for rel_path, abs_path in collected.items():
70
- if abs_path:
71
- console.print(f"[OK] Found: {rel_path} -> {abs_path}", style="green")
72
- else:
73
- console.print(f"[INFO] Optional file not found: {rel_path}", style="yellow")
74
- except FileNotFoundError as e:
75
- console.print(f"[ERROR] {e}", style="bold red")
76
- raise click.Abort()
77
-
78
- # Auto-detect project type from GDS file name if not provided
79
- gds_dir = Path(project_root) / 'gds'
80
- found_types = []
81
- gds_file_path = None
82
- for gds_name, gds_type in GDS_TYPE_MAP.items():
83
- candidate = gds_dir / gds_name
84
- if candidate.exists():
85
- found_types.append(gds_type)
86
- gds_file_path = str(candidate)
87
- if project_type:
88
- detected_type = project_type
89
- else:
90
- if len(found_types) == 0:
91
- console.print("[ERROR] No recognized GDS file found for project type detection.", style="bold red")
92
- raise click.Abort()
93
- elif len(found_types) > 1:
94
- console.print(f"[ERROR] Multiple GDS types found: {found_types}. Only one project type is allowed per project.", style="bold red")
95
- raise click.Abort()
96
- else:
97
- detected_type = found_types[0]
98
- console.print(f"[INFO] Detected project type: {detected_type}", style="bold cyan")
99
- # Use the detected GDS file for upload and hash
100
- if gds_file_path:
101
- collected['gds/user_project_wrapper.gds'] = gds_file_path
102
- # Prepare CLI overrides for project.json
103
- cli_overrides = {
104
- "project_id": project_id,
105
- "project_name": project_name,
106
- "project_type": detected_type,
107
- "sftp_username": sftp_username,
108
- }
109
- cf_dir = ensure_cf_directory(project_root)
110
- console.print(f"[INFO] Generating/updating project.json in {cf_dir}", style="bold cyan")
111
- project_json_path = update_or_create_project_json(
112
- cf_dir=str(cf_dir),
113
- gds_path=collected["gds/user_project_wrapper.gds"],
114
- cli_overrides=cli_overrides,
115
- existing_json_path=collected.get(".cf/project.json")
116
- )
117
- console.print(f"[OK] project.json ready: {project_json_path}", style="green")
118
-
119
- # SFTP upload or dry-run
120
- final_project_name = project_name or (
121
- cli_overrides.get("project_name") or Path(project_root).name
122
- )
123
- sftp_base = f"incoming/projects/{final_project_name}"
124
- upload_map = {
125
- ".cf/project.json": project_json_path,
126
- "gds/user_project_wrapper.gds": collected["gds/user_project_wrapper.gds"],
127
- "verilog/rtl/user_defines.v": collected["verilog/rtl/user_defines.v"],
128
- }
129
- if dry_run:
130
- console.print("[DRY-RUN] The following files would be uploaded:", style="bold magenta")
131
- for rel_path, local_path in upload_map.items():
132
- if local_path:
133
- remote_path = os.path.join(sftp_base, rel_path)
134
- console.print(f" {local_path} -> {remote_path}", style="magenta")
135
- console.print("[DRY-RUN] No files were uploaded.", style="bold magenta")
136
- return
137
-
138
- console.print(f"[INFO] Connecting to SFTP: {sftp_host} as {sftp_username}", style="bold cyan")
139
- transport = None
140
- try:
141
- sftp, transport = sftp_connect(
142
- host=sftp_host,
143
- username=sftp_username,
144
- password=password,
145
- key_path=key_path
146
- )
147
- # Ensure the project directory exists before uploading
148
- sftp_project_dir = f"incoming/projects/{final_project_name}"
149
- sftp_ensure_dirs(sftp, sftp_project_dir)
150
- except Exception as e:
151
- console.print(f"[ERROR] Failed to connect to SFTP: {e}", style="bold red")
152
- raise click.Abort()
153
- try:
154
- for rel_path, local_path in upload_map.items():
155
- if local_path:
156
- remote_path = os.path.join(sftp_base, rel_path)
157
- upload_with_progress(
158
- sftp,
159
- local_path=local_path,
160
- remote_path=remote_path,
161
- force_overwrite=force_overwrite
162
- )
163
- console.print(f"[SUCCESS] All files uploaded to {sftp_base}", style="bold green")
164
- except Exception as e:
165
- console.print(f"[ERROR] SFTP upload failed: {e}", style="bold red")
166
- raise click.Abort()
167
- finally:
168
- if transport:
169
- sftp.close()
170
- transport.close()
171
-
172
- if __name__ == "__main__":
173
- main()
File without changes