Skip to content

Commit 789a95e

Browse files
authored
Merge pull request #9 from ni-kismet/users/fvisser/shared-code-refactor
Universal Pagination & Modernization
2 parents fd79504 + 8c8b254 commit 789a95e

File tree

10 files changed

+1469
-407
lines changed

10 files changed

+1469
-407
lines changed

.github/copilot-instructions.md

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,41 @@
33
## General Best Practices
44

55
- All code must be PEP8-compliant and pass linting (black, ni-python-styleguide).
6-
- All new and modified code must include appropriate docstrings and type hints where possible.
7-
- All public functions and classes must have docstrings.
6+
- All new and modified code must include comprehensive type hints using `typing` module (Any, Dict, List, Optional, Tuple).
7+
- All public functions and classes must have docstrings following Google docstring format.
88
- Use environment variables and keyring for all credentials and sensitive data.
99
- All CLI commands must provide clear help text and validation.
10-
- Use Click for all CLI interfaces (not Typer).
10+
- Use Click for all CLI interfaces (not Typer) with inline @click.option decorators.
1111
- All API interactions must handle errors gracefully and provide user-friendly messages.
1212
- All new features and bugfixes must include or update unit tests.
1313
- All code must be cross-platform (Windows, macOS, Linux) unless otherwise specified.
1414
- All generated, build, and cache files must be excluded via `.gitignore`.
1515

16+
## CLI Architecture & Patterns
17+
18+
### Command Structure
19+
- Use inline @click.option decorators directly on command functions (avoid decorator abstractions)
20+
- All command modules follow the pattern: `register_{module}_commands(cli: Any) -> None`
21+
- All command functions must have complete type annotations: parameters and return types
22+
- Command groups use `@cli.group()` pattern with typed function signatures
23+
24+
### Universal Response Handling
25+
- Use `UniversalResponseHandler` class for consistent response processing across all commands
26+
- Use `handle_api_error(exc)` function for standardized API error handling with appropriate exit codes
27+
- Use `format_success(message, data)` function for consistent success message formatting
28+
- For mock responses in tests, use type annotation pattern: `resp: Any = MockResponse()` for Pylance compatibility
29+
1630
## CLI Best Practices (Based on https://clig.dev)
1731

1832
### Output & Formatting
1933
- All list commands must support `--format/-f` option with `table` (default) and `json` formats
20-
- Use `--output` for file path outputs (export, save operations)
34+
- All list commands must support `--take/-t` option with default of 25 items
35+
- Use `--output/-o` for file path outputs (export, save operations)
36+
- Use `table_utils.output_formatted_list()` for consistent table formatting with box-drawing characters
37+
- Use `cli_utils.paginate_list_output()` for interactive pagination of table results (25 items per page with Y/n prompts)
2138
- Use consistent visual indicators: `` for success messages, `` for error messages
2239
- Send all error messages to stderr using `click.echo(..., err=True)`
23-
- For JSON output, display all data at once (no pagination); for table output, retain pagination
40+
- For JSON output, display all data at once (no pagination); for table output, use interactive pagination
2441
- Handle empty results gracefully: `[]` for JSON, descriptive message for table format
2542

2643
### Error Handling & Exit Codes
@@ -31,9 +48,26 @@
3148
- `NOT_FOUND = 3`: Resource not found
3249
- `PERMISSION_DENIED = 4`: Permission/authorization error
3350
- `NETWORK_ERROR = 5`: Network connectivity error
34-
- Use `handle_api_error(exc)` function for consistent API error handling
51+
- Use `handle_api_error(exc)` function for consistent API error handling with automatic exit code selection
3552
- Use `format_success(message, data)` function for consistent success messages
3653
- Always exit with appropriate codes using `sys.exit(ExitCodes.*)` rather than raising ClickException
54+
- Use `cli_utils.validate_output_format()` to standardize format validation
55+
56+
### Code Organization & Utilities
57+
- Use `cli_utils.py` for common CLI utilities like validation and resource resolution
58+
- Use `table_utils.py` for professional table formatting with box-drawing characters
59+
- Use `universal_handlers.py` for standardized response processing across all commands
60+
- Use `workspace_utils.py` for workspace-specific operations and filtering
61+
- All utility modules must follow the established type annotation patterns
62+
63+
### Mock Response Pattern for Testing
64+
- For test compatibility with type checkers, use: `resp: Any = FilteredResponse()` or `resp: Any = MockResponse()`
65+
- FilteredResponse and MockResponse classes must implement:
66+
```python
67+
def json(self) -> Dict[str, Any]: ...
68+
@property
69+
def status_code(self) -> int: ...
70+
```
3771

3872
### Command Structure
3973
- All commands must validate required parameters and provide helpful error messages
@@ -62,7 +96,7 @@
6296
- All code must pass CI (lint, test, build) before merging.
6397
- All new features must be documented in `README.md`.
6498
- All code must be reviewed by at least one other developer.
65-
- All new CLI commands must include JSON output support via `--output/-o` option.
99+
- All new CLI commands must include JSON output support via `--format/-f` option.
66100
- All error handling must use standardized exit codes and consistent formatting.
67101

68102
## Copilot-Specific Instructions
@@ -73,14 +107,19 @@
73107
3. Report any failures or issues to the user.
74108
- If you add a new CLI command, ensure it:
75109
- Is covered by a unit test in `tests/unit/`
76-
- Supports `--output/-o` option with `table` and `json` formats
110+
- Supports `--format/-f` option with `table` and `json` formats
111+
- Supports `--take/-t` option with default of 25 items for list commands
77112
- Uses consistent error handling with appropriate exit codes
78113
- Follows the success/error message formatting standards
114+
- Uses `UniversalResponseHandler` for response processing with `enable_pagination=True`
115+
- Uses `table_utils.output_formatted_list()` for table output
116+
- Implements interactive pagination for table results (Y/n prompts for next 25 results)
79117
- If you update the CLI interface, update the `README.md` accordingly.
80118
- Never commit or suggest committing files listed in `.gitignore`.
81119
- When implementing list commands, ensure JSON output shows all results (no pagination).
82120
- Use `handle_api_error()` for all API error handling instead of generic Click exceptions.
83121
- Use `format_success()` for all success messages to maintain consistency.
122+
- For type compatibility with Pylance, use `: Any = MockResponse()` pattern for mock responses.
84123

85124
---
86125

slcli/cli_formatters.py

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
"""Unified table formatters for all CLI resource types."""
2+
3+
from datetime import datetime
4+
from typing import Any, Dict, List
5+
6+
7+
def format_templates_table(template: Dict[str, Any]) -> List[str]:
8+
"""Format template data for table display."""
9+
name = template.get("name", "Unknown")
10+
version = template.get("version", "N/A")
11+
created = _format_timestamp(template.get("createdTimestamp"))
12+
status = template.get("status", "Unknown")
13+
14+
return [name, version, created, status]
15+
16+
17+
def format_users_table(user: Dict[str, Any]) -> List[str]:
18+
"""Format user data for table display."""
19+
username = user.get("username", "Unknown")
20+
email = user.get("email", "N/A")
21+
role = user.get("role", "N/A")
22+
status = "Active" if user.get("enabled", True) else "Disabled"
23+
24+
return [username, email, role, status]
25+
26+
27+
def format_workflows_table(workflow: Dict[str, Any]) -> List[str]:
28+
"""Format workflow data for table display."""
29+
name = workflow.get("name", "Unknown")
30+
status = workflow.get("status", "Unknown")
31+
last_run = _format_timestamp(workflow.get("lastRunTimestamp"))
32+
duration = _format_duration(workflow.get("lastRunDuration"))
33+
34+
return [name, status, last_run, duration]
35+
36+
37+
def format_notebooks_table(notebook: Dict[str, Any]) -> List[str]:
38+
"""Format notebook data for table display."""
39+
name = notebook.get("name", "Unknown")
40+
notebook_type = notebook.get("type", "Unknown")
41+
modified = _format_timestamp(notebook.get("modifiedTimestamp"))
42+
size = _format_file_size(notebook.get("size", 0))
43+
44+
return [name, notebook_type, modified, size]
45+
46+
47+
def format_workspaces_table(workspace: Dict[str, Any]) -> List[str]:
48+
"""Format workspace data for table display."""
49+
name = workspace.get("name", "Unknown")
50+
workspace_type = workspace.get("type", "Unknown")
51+
file_count = str(workspace.get("fileCount", 0))
52+
modified = _format_timestamp(workspace.get("modifiedTimestamp"))
53+
54+
return [name, workspace_type, file_count, modified]
55+
56+
57+
def format_dff_files_table(file_info: Dict[str, Any]) -> List[str]:
58+
"""Format DFF file data for table display."""
59+
name = file_info.get("name", "Unknown")
60+
size = _format_file_size(file_info.get("size", 0))
61+
modified = _format_timestamp(file_info.get("modifiedTimestamp"))
62+
file_type = file_info.get("type", "file").title()
63+
64+
return [name, size, modified, file_type]
65+
66+
67+
def format_dff_data_table(data_info: Dict[str, Any]) -> List[str]:
68+
"""Format DFF data entry for table display."""
69+
id_val = data_info.get("id", "Unknown")
70+
name = data_info.get("name", "N/A")
71+
created = _format_timestamp(data_info.get("createdTimestamp"))
72+
size = _format_file_size(data_info.get("size", 0))
73+
74+
return [id_val, name, created, size]
75+
76+
77+
def format_tags_table(tag: Dict[str, Any]) -> List[str]:
78+
"""Format tag data for table display."""
79+
name = tag.get("name", "Unknown")
80+
tag_type = tag.get("type", "Unknown")
81+
count = str(tag.get("dataCount", 0))
82+
created = _format_timestamp(tag.get("createdTimestamp"))
83+
84+
return [name, tag_type, count, created]
85+
86+
87+
def format_systems_table(system: Dict[str, Any]) -> List[str]:
88+
"""Format system data for table display."""
89+
name = system.get("name", "Unknown")
90+
status = system.get("status", "Unknown")
91+
ip_address = system.get("ipAddress", "N/A")
92+
last_seen = _format_timestamp(system.get("lastSeenTimestamp"))
93+
94+
return [name, status, ip_address, last_seen]
95+
96+
97+
def format_assets_table(asset: Dict[str, Any]) -> List[str]:
98+
"""Format asset data for table display."""
99+
name = asset.get("name", "Unknown")
100+
asset_type = asset.get("type", "Unknown")
101+
location = asset.get("location", "N/A")
102+
status = asset.get("status", "Unknown")
103+
104+
return [name, asset_type, location, status]
105+
106+
107+
def _format_timestamp(timestamp: Any) -> str:
108+
"""Format timestamp for table display."""
109+
if not timestamp:
110+
return "N/A"
111+
112+
try:
113+
if isinstance(timestamp, str):
114+
# Handle ISO format timestamps
115+
dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
116+
elif isinstance(timestamp, (int, float)):
117+
# Handle Unix timestamps
118+
dt = datetime.fromtimestamp(timestamp)
119+
else:
120+
return "N/A"
121+
122+
# Return relative time if recent, otherwise date
123+
now = datetime.now()
124+
diff = now - dt.replace(tzinfo=None)
125+
126+
if diff.days == 0:
127+
return dt.strftime("%H:%M")
128+
elif diff.days < 7:
129+
return f"{diff.days}d ago"
130+
else:
131+
return dt.strftime("%Y-%m-%d")
132+
133+
except (ValueError, TypeError, AttributeError):
134+
return "N/A"
135+
136+
137+
def _format_duration(duration: Any) -> str:
138+
"""Format duration for table display."""
139+
if not duration:
140+
return "N/A"
141+
142+
try:
143+
if isinstance(duration, str):
144+
return duration
145+
elif isinstance(duration, (int, float)):
146+
# Convert seconds to readable format
147+
if duration < 60:
148+
return f"{duration:.1f}s"
149+
elif duration < 3600:
150+
return f"{duration // 60:.0f}m {duration % 60:.0f}s"
151+
else:
152+
hours = duration // 3600
153+
minutes = (duration % 3600) // 60
154+
return f"{hours:.0f}h {minutes:.0f}m"
155+
else:
156+
return "N/A"
157+
except (ValueError, TypeError):
158+
return "N/A"
159+
160+
161+
def _format_file_size(size: Any) -> str:
162+
"""Format file size for table display."""
163+
if not size or size == 0:
164+
return "0 B"
165+
166+
try:
167+
size = float(size)
168+
units = ["B", "KB", "MB", "GB", "TB"]
169+
unit_index = 0
170+
171+
while size >= 1024 and unit_index < len(units) - 1:
172+
size /= 1024
173+
unit_index += 1
174+
175+
if unit_index == 0:
176+
return f"{size:.0f} {units[unit_index]}"
177+
else:
178+
return f"{size:.1f} {units[unit_index]}"
179+
180+
except (ValueError, TypeError):
181+
return "N/A"
182+
183+
184+
# Formatter mapping for dynamic lookup
185+
FORMATTER_MAP = {
186+
"template": format_templates_table,
187+
"templates": format_templates_table,
188+
"user": format_users_table,
189+
"users": format_users_table,
190+
"workflow": format_workflows_table,
191+
"workflows": format_workflows_table,
192+
"notebook": format_notebooks_table,
193+
"notebooks": format_notebooks_table,
194+
"workspace": format_workspaces_table,
195+
"workspaces": format_workspaces_table,
196+
"file": format_dff_files_table,
197+
"files": format_dff_files_table,
198+
"data": format_dff_data_table,
199+
"tag": format_tags_table,
200+
"tags": format_tags_table,
201+
"system": format_systems_table,
202+
"systems": format_systems_table,
203+
"asset": format_assets_table,
204+
"assets": format_assets_table,
205+
}
206+
207+
208+
def get_formatter(resource_type: str):
209+
"""Get the appropriate formatter function for a resource type."""
210+
return FORMATTER_MAP.get(resource_type.lower())

0 commit comments

Comments
 (0)