scc-cli 1.4.0__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.
Potentially problematic release.
This version of scc-cli might be problematic. Click here for more details.
- scc_cli/__init__.py +15 -0
- scc_cli/audit/__init__.py +37 -0
- scc_cli/audit/parser.py +191 -0
- scc_cli/audit/reader.py +180 -0
- scc_cli/auth.py +145 -0
- scc_cli/claude_adapter.py +485 -0
- scc_cli/cli.py +259 -0
- scc_cli/cli_admin.py +683 -0
- scc_cli/cli_audit.py +245 -0
- scc_cli/cli_common.py +166 -0
- scc_cli/cli_config.py +527 -0
- scc_cli/cli_exceptions.py +705 -0
- scc_cli/cli_helpers.py +244 -0
- scc_cli/cli_init.py +272 -0
- scc_cli/cli_launch.py +1400 -0
- scc_cli/cli_org.py +1433 -0
- scc_cli/cli_support.py +322 -0
- scc_cli/cli_team.py +858 -0
- scc_cli/cli_worktree.py +865 -0
- scc_cli/config.py +583 -0
- scc_cli/console.py +562 -0
- scc_cli/constants.py +79 -0
- scc_cli/contexts.py +377 -0
- scc_cli/deprecation.py +54 -0
- scc_cli/deps.py +189 -0
- scc_cli/docker/__init__.py +127 -0
- scc_cli/docker/core.py +466 -0
- scc_cli/docker/credentials.py +726 -0
- scc_cli/docker/launch.py +603 -0
- scc_cli/doctor/__init__.py +99 -0
- scc_cli/doctor/checks.py +1082 -0
- scc_cli/doctor/render.py +346 -0
- scc_cli/doctor/types.py +66 -0
- scc_cli/errors.py +288 -0
- scc_cli/evaluation/__init__.py +27 -0
- scc_cli/evaluation/apply_exceptions.py +207 -0
- scc_cli/evaluation/evaluate.py +97 -0
- scc_cli/evaluation/models.py +80 -0
- scc_cli/exit_codes.py +55 -0
- scc_cli/git.py +1405 -0
- scc_cli/json_command.py +166 -0
- scc_cli/json_output.py +96 -0
- scc_cli/kinds.py +62 -0
- scc_cli/marketplace/__init__.py +123 -0
- scc_cli/marketplace/compute.py +377 -0
- scc_cli/marketplace/constants.py +87 -0
- scc_cli/marketplace/managed.py +135 -0
- scc_cli/marketplace/materialize.py +723 -0
- scc_cli/marketplace/normalize.py +548 -0
- scc_cli/marketplace/render.py +238 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +502 -0
- scc_cli/marketplace/sync.py +257 -0
- scc_cli/marketplace/team_cache.py +195 -0
- scc_cli/marketplace/team_fetch.py +688 -0
- scc_cli/marketplace/trust.py +244 -0
- scc_cli/models/__init__.py +41 -0
- scc_cli/models/exceptions.py +273 -0
- scc_cli/models/plugin_audit.py +434 -0
- scc_cli/org_templates.py +269 -0
- scc_cli/output_mode.py +167 -0
- scc_cli/panels.py +113 -0
- scc_cli/platform.py +350 -0
- scc_cli/profiles.py +1034 -0
- scc_cli/remote.py +443 -0
- scc_cli/schemas/__init__.py +1 -0
- scc_cli/schemas/org-v1.schema.json +456 -0
- scc_cli/schemas/team-config.v1.schema.json +163 -0
- scc_cli/sessions.py +425 -0
- scc_cli/setup.py +582 -0
- scc_cli/source_resolver.py +470 -0
- scc_cli/stats.py +378 -0
- scc_cli/stores/__init__.py +13 -0
- scc_cli/stores/exception_store.py +251 -0
- scc_cli/subprocess_utils.py +88 -0
- scc_cli/teams.py +339 -0
- scc_cli/templates/__init__.py +2 -0
- scc_cli/templates/org/__init__.py +0 -0
- scc_cli/templates/org/minimal.json +19 -0
- scc_cli/templates/org/reference.json +74 -0
- scc_cli/templates/org/strict.json +38 -0
- scc_cli/templates/org/teams.json +42 -0
- scc_cli/templates/statusline.sh +75 -0
- scc_cli/theme.py +348 -0
- scc_cli/ui/__init__.py +124 -0
- scc_cli/ui/branding.py +68 -0
- scc_cli/ui/chrome.py +395 -0
- scc_cli/ui/dashboard/__init__.py +62 -0
- scc_cli/ui/dashboard/_dashboard.py +669 -0
- scc_cli/ui/dashboard/loaders.py +369 -0
- scc_cli/ui/dashboard/models.py +184 -0
- scc_cli/ui/dashboard/orchestrator.py +337 -0
- scc_cli/ui/formatters.py +443 -0
- scc_cli/ui/gate.py +350 -0
- scc_cli/ui/help.py +157 -0
- scc_cli/ui/keys.py +521 -0
- scc_cli/ui/list_screen.py +431 -0
- scc_cli/ui/picker.py +700 -0
- scc_cli/ui/prompts.py +200 -0
- scc_cli/ui/wizard.py +490 -0
- scc_cli/update.py +680 -0
- scc_cli/utils/__init__.py +39 -0
- scc_cli/utils/fixit.py +264 -0
- scc_cli/utils/fuzzy.py +124 -0
- scc_cli/utils/locks.py +101 -0
- scc_cli/utils/ttl.py +376 -0
- scc_cli/validate.py +455 -0
- scc_cli-1.4.0.dist-info/METADATA +369 -0
- scc_cli-1.4.0.dist-info/RECORD +112 -0
- scc_cli-1.4.0.dist-info/WHEEL +4 -0
- scc_cli-1.4.0.dist-info/entry_points.txt +2 -0
- scc_cli-1.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Utility modules for SCC CLI."""
|
|
2
|
+
|
|
3
|
+
from scc_cli.utils.fixit import (
|
|
4
|
+
format_block_message,
|
|
5
|
+
format_command_for_terminal,
|
|
6
|
+
generate_policy_exception_command,
|
|
7
|
+
generate_unblock_command,
|
|
8
|
+
get_terminal_width,
|
|
9
|
+
)
|
|
10
|
+
from scc_cli.utils.ttl import (
|
|
11
|
+
DEFAULT_TTL,
|
|
12
|
+
MAX_TTL,
|
|
13
|
+
calculate_expiration,
|
|
14
|
+
format_expiration,
|
|
15
|
+
format_relative,
|
|
16
|
+
parse_expires_at,
|
|
17
|
+
parse_ttl,
|
|
18
|
+
parse_until,
|
|
19
|
+
validate_ttl_duration,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
# TTL utilities
|
|
24
|
+
"DEFAULT_TTL",
|
|
25
|
+
"MAX_TTL",
|
|
26
|
+
"calculate_expiration",
|
|
27
|
+
"format_expiration",
|
|
28
|
+
"format_relative",
|
|
29
|
+
"parse_expires_at",
|
|
30
|
+
"parse_ttl",
|
|
31
|
+
"parse_until",
|
|
32
|
+
"validate_ttl_duration",
|
|
33
|
+
# Fix-it utilities
|
|
34
|
+
"format_block_message",
|
|
35
|
+
"format_command_for_terminal",
|
|
36
|
+
"generate_policy_exception_command",
|
|
37
|
+
"generate_unblock_command",
|
|
38
|
+
"get_terminal_width",
|
|
39
|
+
]
|
scc_cli/utils/fixit.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Provide fix-it command generation utilities.
|
|
2
|
+
|
|
3
|
+
Generate ready-to-copy commands for blocked or denied items,
|
|
4
|
+
helping users quickly unblock themselves.
|
|
5
|
+
|
|
6
|
+
Two types of fix-it commands:
|
|
7
|
+
- Unblock commands: For delegation denials (local override)
|
|
8
|
+
- Policy exception commands: For security blocks (requires PR)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_terminal_width() -> int:
|
|
19
|
+
"""Get current terminal width.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Terminal width in columns. Defaults to 80 if not detectable.
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
size = shutil.get_terminal_size(fallback=(80, 24))
|
|
26
|
+
return max(40, min(size.columns, 500)) # Clamp to reasonable range
|
|
27
|
+
except Exception:
|
|
28
|
+
return 80
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _needs_quoting(value: str) -> bool:
|
|
32
|
+
"""Check if a value needs shell quoting.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
value: The string to check
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
True if the value contains special characters requiring quoting
|
|
39
|
+
"""
|
|
40
|
+
# Simple alphanumeric with dashes, underscores, dots, colons, slashes
|
|
41
|
+
safe_pattern = r"^[a-zA-Z0-9_.\-:/]+$"
|
|
42
|
+
return not re.match(safe_pattern, value)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _shell_quote(value: str) -> str:
|
|
46
|
+
"""Quote a value for safe shell usage.
|
|
47
|
+
|
|
48
|
+
Uses single quotes to prevent variable expansion.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
value: The string to quote
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Safely quoted string
|
|
55
|
+
"""
|
|
56
|
+
if not _needs_quoting(value):
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
# Use single quotes, escape any existing single quotes
|
|
60
|
+
escaped = value.replace("'", "'\"'\"'")
|
|
61
|
+
return f"'{escaped}'"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _target_type_to_flag(target_type: str) -> str:
|
|
65
|
+
"""Convert target type to CLI flag name.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
target_type: One of "plugin", "mcp_server", "base_image"
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
CLI flag name like "--allow-plugin"
|
|
72
|
+
"""
|
|
73
|
+
type_map = {
|
|
74
|
+
"plugin": "--allow-plugin",
|
|
75
|
+
"mcp_server": "--allow-mcp",
|
|
76
|
+
"base_image": "--allow-image",
|
|
77
|
+
}
|
|
78
|
+
return type_map.get(target_type, f"--allow-{target_type}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def generate_unblock_command(
|
|
82
|
+
target: str,
|
|
83
|
+
target_type: str,
|
|
84
|
+
ttl: str = "8h",
|
|
85
|
+
) -> str:
|
|
86
|
+
"""Generate an unblock command for a delegation denial.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
target: The denied item (plugin name, server name, image ref)
|
|
90
|
+
target_type: One of "plugin", "mcp_server", "base_image"
|
|
91
|
+
ttl: Time-to-live for the override (default 8h)
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Ready-to-copy unblock command
|
|
95
|
+
"""
|
|
96
|
+
quoted_target = _shell_quote(target)
|
|
97
|
+
return f'scc unblock {quoted_target} --ttl {ttl} --reason "..."'
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def generate_policy_exception_command(
|
|
101
|
+
target: str,
|
|
102
|
+
target_type: str,
|
|
103
|
+
ttl: str = "8h",
|
|
104
|
+
) -> str:
|
|
105
|
+
"""Generate a policy exception command for a security block.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
target: The blocked item (plugin name, server name, image ref)
|
|
109
|
+
target_type: One of "plugin", "mcp_server", "base_image"
|
|
110
|
+
ttl: Time-to-live for the exception (default 8h)
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Ready-to-copy policy exception command
|
|
114
|
+
"""
|
|
115
|
+
flag = _target_type_to_flag(target_type)
|
|
116
|
+
quoted_target = _shell_quote(target)
|
|
117
|
+
|
|
118
|
+
return f'scc exceptions create --policy --id INC-... {flag} {quoted_target} --ttl {ttl} --reason "..."'
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def format_command_for_terminal(
|
|
122
|
+
command: str,
|
|
123
|
+
max_width: int | None = None,
|
|
124
|
+
indent: str = " ",
|
|
125
|
+
) -> str:
|
|
126
|
+
"""Format a command to fit within terminal width.
|
|
127
|
+
|
|
128
|
+
For very long commands, breaks at logical points with backslash continuations.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
command: The command to format
|
|
132
|
+
max_width: Maximum line width (defaults to terminal width)
|
|
133
|
+
indent: Indentation for continuation lines
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Formatted command (possibly multi-line)
|
|
137
|
+
"""
|
|
138
|
+
if max_width is None:
|
|
139
|
+
max_width = get_terminal_width()
|
|
140
|
+
|
|
141
|
+
# If command fits, return as-is
|
|
142
|
+
if len(command) <= max_width:
|
|
143
|
+
return command
|
|
144
|
+
|
|
145
|
+
# Try to break at flag boundaries
|
|
146
|
+
parts = []
|
|
147
|
+
current_part = ""
|
|
148
|
+
|
|
149
|
+
# Split by spaces, keeping quoted strings together
|
|
150
|
+
tokens = _tokenize_command(command)
|
|
151
|
+
|
|
152
|
+
for token in tokens:
|
|
153
|
+
test_line = current_part + (" " if current_part else "") + token
|
|
154
|
+
|
|
155
|
+
if len(test_line) > max_width - 2 and current_part: # -2 for " \"
|
|
156
|
+
parts.append(current_part + " \\")
|
|
157
|
+
current_part = indent + token
|
|
158
|
+
else:
|
|
159
|
+
current_part = test_line
|
|
160
|
+
|
|
161
|
+
if current_part:
|
|
162
|
+
parts.append(current_part)
|
|
163
|
+
|
|
164
|
+
return "\n".join(parts)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _tokenize_command(command: str) -> list[str]:
|
|
168
|
+
"""Split a command into tokens, respecting quoted strings.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
command: The command string
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
List of tokens
|
|
175
|
+
"""
|
|
176
|
+
tokens = []
|
|
177
|
+
current = ""
|
|
178
|
+
in_quotes = False
|
|
179
|
+
quote_char = ""
|
|
180
|
+
|
|
181
|
+
for char in command:
|
|
182
|
+
if char in "\"'" and not in_quotes:
|
|
183
|
+
in_quotes = True
|
|
184
|
+
quote_char = char
|
|
185
|
+
current += char
|
|
186
|
+
elif char == quote_char and in_quotes:
|
|
187
|
+
in_quotes = False
|
|
188
|
+
current += char
|
|
189
|
+
elif char == " " and not in_quotes:
|
|
190
|
+
if current:
|
|
191
|
+
tokens.append(current)
|
|
192
|
+
current = ""
|
|
193
|
+
else:
|
|
194
|
+
current += char
|
|
195
|
+
|
|
196
|
+
if current:
|
|
197
|
+
tokens.append(current)
|
|
198
|
+
|
|
199
|
+
return tokens
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def format_block_message(
|
|
203
|
+
target: str,
|
|
204
|
+
target_type: str,
|
|
205
|
+
block_type: Literal["security", "delegation"],
|
|
206
|
+
blocked_by: str | None = None,
|
|
207
|
+
reason: str | None = None,
|
|
208
|
+
) -> str:
|
|
209
|
+
"""Format a complete block/denial message with fix-it command.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
target: The blocked/denied item
|
|
213
|
+
target_type: One of "plugin", "mcp_server", "base_image"
|
|
214
|
+
block_type: "security" for blocks, "delegation" for denials
|
|
215
|
+
blocked_by: Pattern that caused the block (for security blocks)
|
|
216
|
+
reason: Reason for denial (for delegation denials)
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Formatted message with fix-it command
|
|
220
|
+
"""
|
|
221
|
+
lines = []
|
|
222
|
+
|
|
223
|
+
if block_type == "security":
|
|
224
|
+
# Security block message
|
|
225
|
+
type_display = _format_target_type(target_type)
|
|
226
|
+
lines.append(f'✗ {type_display} "{target}" blocked by org security policy')
|
|
227
|
+
|
|
228
|
+
if blocked_by:
|
|
229
|
+
lines.append(f" Blocked by: {blocked_by}")
|
|
230
|
+
|
|
231
|
+
lines.append("")
|
|
232
|
+
lines.append(" To request policy exception (requires PR approval):")
|
|
233
|
+
cmd = generate_policy_exception_command(target, target_type)
|
|
234
|
+
lines.append(f" {cmd}")
|
|
235
|
+
|
|
236
|
+
else: # delegation
|
|
237
|
+
# Delegation denial message
|
|
238
|
+
type_display = _format_target_type(target_type)
|
|
239
|
+
denial_reason = reason or "team not delegated for additions"
|
|
240
|
+
lines.append(f'✗ {type_display} "{target}" denied: {denial_reason}')
|
|
241
|
+
|
|
242
|
+
lines.append("")
|
|
243
|
+
lines.append(" To unblock locally for 8h:")
|
|
244
|
+
cmd = generate_unblock_command(target, target_type)
|
|
245
|
+
lines.append(f" {cmd}")
|
|
246
|
+
|
|
247
|
+
return "\n".join(lines)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _format_target_type(target_type: str) -> str:
|
|
251
|
+
"""Format target type for display.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
target_type: The internal target type
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
Human-readable display name
|
|
258
|
+
"""
|
|
259
|
+
type_map = {
|
|
260
|
+
"plugin": "Plugin",
|
|
261
|
+
"mcp_server": "MCP server",
|
|
262
|
+
"base_image": "Base image",
|
|
263
|
+
}
|
|
264
|
+
return type_map.get(target_type, target_type.replace("_", " ").title())
|
scc_cli/utils/fuzzy.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Provide fuzzy matching utilities for DX improvements.
|
|
2
|
+
|
|
3
|
+
Offer fuzzy string matching to suggest corrections when users
|
|
4
|
+
make typos or partial matches in CLI commands.
|
|
5
|
+
|
|
6
|
+
Use Levenshtein-based similarity for >80% threshold matching.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def similarity_score(s1: str, s2: str) -> float:
|
|
13
|
+
"""Calculate similarity score between two strings.
|
|
14
|
+
|
|
15
|
+
Uses normalized Levenshtein distance for similarity scoring.
|
|
16
|
+
Returns value between 0.0 (completely different) and 1.0 (identical).
|
|
17
|
+
|
|
18
|
+
The comparison is case-insensitive.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
s1: First string
|
|
22
|
+
s2: Second string
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Similarity score from 0.0 to 1.0
|
|
26
|
+
"""
|
|
27
|
+
# Handle edge cases
|
|
28
|
+
if not s1 and not s2:
|
|
29
|
+
return 1.0 # Both empty = identical
|
|
30
|
+
if not s1 or not s2:
|
|
31
|
+
return 0.0 # One empty = no similarity
|
|
32
|
+
|
|
33
|
+
# Case-insensitive comparison
|
|
34
|
+
s1_lower = s1.lower()
|
|
35
|
+
s2_lower = s2.lower()
|
|
36
|
+
|
|
37
|
+
# Calculate Levenshtein distance
|
|
38
|
+
distance = _levenshtein_distance(s1_lower, s2_lower)
|
|
39
|
+
|
|
40
|
+
# Normalize by max length to get similarity
|
|
41
|
+
max_len = max(len(s1_lower), len(s2_lower))
|
|
42
|
+
return 1.0 - (distance / max_len)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _levenshtein_distance(s1: str, s2: str) -> int:
|
|
46
|
+
"""Calculate Levenshtein (edit) distance between two strings.
|
|
47
|
+
|
|
48
|
+
Uses dynamic programming approach for efficiency.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
s1: First string
|
|
52
|
+
s2: Second string
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Minimum number of single-character edits needed
|
|
56
|
+
"""
|
|
57
|
+
if len(s1) < len(s2):
|
|
58
|
+
return _levenshtein_distance(s2, s1)
|
|
59
|
+
|
|
60
|
+
if len(s2) == 0:
|
|
61
|
+
return len(s1)
|
|
62
|
+
|
|
63
|
+
# Use only two rows for space efficiency
|
|
64
|
+
previous_row = list(range(len(s2) + 1))
|
|
65
|
+
current_row = [0] * (len(s2) + 1)
|
|
66
|
+
|
|
67
|
+
for i, c1 in enumerate(s1):
|
|
68
|
+
current_row[0] = i + 1
|
|
69
|
+
for j, c2 in enumerate(s2):
|
|
70
|
+
# Cost is 0 if characters match, 1 otherwise
|
|
71
|
+
insertions = previous_row[j + 1] + 1
|
|
72
|
+
deletions = current_row[j] + 1
|
|
73
|
+
substitutions = previous_row[j] + (0 if c1 == c2 else 1)
|
|
74
|
+
current_row[j + 1] = min(insertions, deletions, substitutions)
|
|
75
|
+
|
|
76
|
+
previous_row, current_row = current_row, previous_row
|
|
77
|
+
|
|
78
|
+
return previous_row[len(s2)]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def find_similar(
|
|
82
|
+
query: str,
|
|
83
|
+
candidates: list[str],
|
|
84
|
+
threshold: float = 0.8,
|
|
85
|
+
max_suggestions: int = 5,
|
|
86
|
+
) -> list[str]:
|
|
87
|
+
"""Find similar candidates to a query string.
|
|
88
|
+
|
|
89
|
+
Returns candidates that are similar to the query (above threshold),
|
|
90
|
+
sorted by similarity (most similar first).
|
|
91
|
+
|
|
92
|
+
If the query exactly matches a candidate, returns empty list
|
|
93
|
+
(caller should use the exact match, not suggestions).
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
query: The string to match against
|
|
97
|
+
candidates: List of possible matches
|
|
98
|
+
threshold: Minimum similarity score (0.0-1.0), default 0.8
|
|
99
|
+
max_suggestions: Maximum number of suggestions to return
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
List of similar candidates, sorted by similarity (most similar first)
|
|
103
|
+
"""
|
|
104
|
+
if not candidates:
|
|
105
|
+
return []
|
|
106
|
+
|
|
107
|
+
# Check for exact match (case-insensitive)
|
|
108
|
+
query_lower = query.lower()
|
|
109
|
+
for candidate in candidates:
|
|
110
|
+
if candidate.lower() == query_lower:
|
|
111
|
+
return [] # Exact match found, no suggestions needed
|
|
112
|
+
|
|
113
|
+
# Calculate similarity scores for all candidates
|
|
114
|
+
scored: list[tuple[str, float]] = []
|
|
115
|
+
for candidate in candidates:
|
|
116
|
+
score = similarity_score(query, candidate)
|
|
117
|
+
if score >= threshold:
|
|
118
|
+
scored.append((candidate, score))
|
|
119
|
+
|
|
120
|
+
# Sort by similarity (descending), then by name (for stable ordering)
|
|
121
|
+
scored.sort(key=lambda x: (-x[1], x[0]))
|
|
122
|
+
|
|
123
|
+
# Return top suggestions
|
|
124
|
+
return [candidate for candidate, _ in scored[:max_suggestions]]
|
scc_cli/utils/locks.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Simple cross-platform file locking utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import fcntl # type: ignore[import-not-found]
|
|
14
|
+
except ImportError: # pragma: no cover - Windows fallback
|
|
15
|
+
fcntl = None
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import msvcrt # type: ignore[import-not-found]
|
|
19
|
+
except ImportError: # pragma: no cover - non-Windows fallback
|
|
20
|
+
msvcrt = None
|
|
21
|
+
|
|
22
|
+
LOCK_DIR = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "scc" / "locks"
|
|
23
|
+
DEFAULT_TIMEOUT = 5.0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _normalize_key(key: str | Path) -> str:
|
|
27
|
+
path = Path(key).expanduser()
|
|
28
|
+
try:
|
|
29
|
+
return str(path.resolve(strict=False))
|
|
30
|
+
except OSError:
|
|
31
|
+
return str(path.absolute())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def lock_path(namespace: str, key: str | Path | None = None) -> Path:
|
|
35
|
+
"""Return a stable lock file path for a namespace/key pair."""
|
|
36
|
+
LOCK_DIR.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
if key is None:
|
|
38
|
+
filename = f"{namespace}.lock"
|
|
39
|
+
else:
|
|
40
|
+
digest = hashlib.sha256(_normalize_key(key).encode()).hexdigest()[:16]
|
|
41
|
+
filename = f"{namespace}-{digest}.lock"
|
|
42
|
+
return LOCK_DIR / filename
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _acquire_lock(lock_file: os.PathLike[str] | os.PathLike[bytes] | int) -> None:
|
|
46
|
+
if fcntl is not None:
|
|
47
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
48
|
+
return
|
|
49
|
+
if msvcrt is not None:
|
|
50
|
+
msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _release_lock(lock_file: os.PathLike[str] | os.PathLike[bytes] | int) -> None:
|
|
55
|
+
if fcntl is not None:
|
|
56
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
57
|
+
return
|
|
58
|
+
if msvcrt is not None:
|
|
59
|
+
try:
|
|
60
|
+
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
|
|
61
|
+
except OSError:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@contextmanager
|
|
66
|
+
def file_lock(path: Path, *, timeout: float = DEFAULT_TIMEOUT, poll: float = 0.1):
|
|
67
|
+
"""Acquire an exclusive file lock with a timeout."""
|
|
68
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
lock_file = path.open("a+")
|
|
70
|
+
acquired = False
|
|
71
|
+
start = time.monotonic()
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
while True:
|
|
75
|
+
try:
|
|
76
|
+
_acquire_lock(lock_file)
|
|
77
|
+
acquired = True
|
|
78
|
+
break
|
|
79
|
+
except OSError as e:
|
|
80
|
+
if e.errno not in (errno.EACCES, errno.EAGAIN):
|
|
81
|
+
raise
|
|
82
|
+
except BlockingIOError:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
if time.monotonic() - start >= timeout:
|
|
86
|
+
raise TimeoutError(f"Timed out waiting for lock: {path}")
|
|
87
|
+
time.sleep(poll)
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
lock_file.seek(0)
|
|
91
|
+
lock_file.truncate()
|
|
92
|
+
lock_file.write(str(os.getpid()))
|
|
93
|
+
lock_file.flush()
|
|
94
|
+
except OSError:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
yield
|
|
98
|
+
finally:
|
|
99
|
+
if acquired:
|
|
100
|
+
_release_lock(lock_file)
|
|
101
|
+
lock_file.close()
|