scc-cli 1.5.3__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.

Files changed (153) hide show
  1. scc_cli/__init__.py +15 -0
  2. scc_cli/audit/__init__.py +37 -0
  3. scc_cli/audit/parser.py +191 -0
  4. scc_cli/audit/reader.py +180 -0
  5. scc_cli/auth.py +145 -0
  6. scc_cli/claude_adapter.py +485 -0
  7. scc_cli/cli.py +311 -0
  8. scc_cli/cli_common.py +190 -0
  9. scc_cli/cli_helpers.py +244 -0
  10. scc_cli/commands/__init__.py +20 -0
  11. scc_cli/commands/admin.py +708 -0
  12. scc_cli/commands/audit.py +246 -0
  13. scc_cli/commands/config.py +528 -0
  14. scc_cli/commands/exceptions.py +696 -0
  15. scc_cli/commands/init.py +272 -0
  16. scc_cli/commands/launch/__init__.py +73 -0
  17. scc_cli/commands/launch/app.py +1247 -0
  18. scc_cli/commands/launch/render.py +309 -0
  19. scc_cli/commands/launch/sandbox.py +135 -0
  20. scc_cli/commands/launch/workspace.py +339 -0
  21. scc_cli/commands/org/__init__.py +49 -0
  22. scc_cli/commands/org/_builders.py +264 -0
  23. scc_cli/commands/org/app.py +41 -0
  24. scc_cli/commands/org/import_cmd.py +267 -0
  25. scc_cli/commands/org/init_cmd.py +269 -0
  26. scc_cli/commands/org/schema_cmd.py +76 -0
  27. scc_cli/commands/org/status_cmd.py +157 -0
  28. scc_cli/commands/org/update_cmd.py +330 -0
  29. scc_cli/commands/org/validate_cmd.py +138 -0
  30. scc_cli/commands/support.py +323 -0
  31. scc_cli/commands/team.py +910 -0
  32. scc_cli/commands/worktree/__init__.py +72 -0
  33. scc_cli/commands/worktree/_helpers.py +57 -0
  34. scc_cli/commands/worktree/app.py +170 -0
  35. scc_cli/commands/worktree/container_commands.py +385 -0
  36. scc_cli/commands/worktree/context_commands.py +61 -0
  37. scc_cli/commands/worktree/session_commands.py +128 -0
  38. scc_cli/commands/worktree/worktree_commands.py +734 -0
  39. scc_cli/config.py +647 -0
  40. scc_cli/confirm.py +20 -0
  41. scc_cli/console.py +562 -0
  42. scc_cli/contexts.py +394 -0
  43. scc_cli/core/__init__.py +68 -0
  44. scc_cli/core/constants.py +101 -0
  45. scc_cli/core/errors.py +297 -0
  46. scc_cli/core/exit_codes.py +91 -0
  47. scc_cli/core/workspace.py +57 -0
  48. scc_cli/deprecation.py +54 -0
  49. scc_cli/deps.py +189 -0
  50. scc_cli/docker/__init__.py +127 -0
  51. scc_cli/docker/core.py +467 -0
  52. scc_cli/docker/credentials.py +726 -0
  53. scc_cli/docker/launch.py +595 -0
  54. scc_cli/doctor/__init__.py +105 -0
  55. scc_cli/doctor/checks/__init__.py +166 -0
  56. scc_cli/doctor/checks/cache.py +314 -0
  57. scc_cli/doctor/checks/config.py +107 -0
  58. scc_cli/doctor/checks/environment.py +182 -0
  59. scc_cli/doctor/checks/json_helpers.py +157 -0
  60. scc_cli/doctor/checks/organization.py +264 -0
  61. scc_cli/doctor/checks/worktree.py +278 -0
  62. scc_cli/doctor/render.py +365 -0
  63. scc_cli/doctor/types.py +66 -0
  64. scc_cli/evaluation/__init__.py +27 -0
  65. scc_cli/evaluation/apply_exceptions.py +207 -0
  66. scc_cli/evaluation/evaluate.py +97 -0
  67. scc_cli/evaluation/models.py +80 -0
  68. scc_cli/git.py +84 -0
  69. scc_cli/json_command.py +166 -0
  70. scc_cli/json_output.py +159 -0
  71. scc_cli/kinds.py +65 -0
  72. scc_cli/marketplace/__init__.py +123 -0
  73. scc_cli/marketplace/adapter.py +74 -0
  74. scc_cli/marketplace/compute.py +377 -0
  75. scc_cli/marketplace/constants.py +87 -0
  76. scc_cli/marketplace/managed.py +135 -0
  77. scc_cli/marketplace/materialize.py +846 -0
  78. scc_cli/marketplace/normalize.py +548 -0
  79. scc_cli/marketplace/render.py +281 -0
  80. scc_cli/marketplace/resolve.py +459 -0
  81. scc_cli/marketplace/schema.py +506 -0
  82. scc_cli/marketplace/sync.py +279 -0
  83. scc_cli/marketplace/team_cache.py +195 -0
  84. scc_cli/marketplace/team_fetch.py +689 -0
  85. scc_cli/marketplace/trust.py +244 -0
  86. scc_cli/models/__init__.py +41 -0
  87. scc_cli/models/exceptions.py +273 -0
  88. scc_cli/models/plugin_audit.py +434 -0
  89. scc_cli/org_templates.py +269 -0
  90. scc_cli/output_mode.py +167 -0
  91. scc_cli/panels.py +113 -0
  92. scc_cli/platform.py +350 -0
  93. scc_cli/profiles.py +960 -0
  94. scc_cli/remote.py +443 -0
  95. scc_cli/schemas/__init__.py +1 -0
  96. scc_cli/schemas/org-v1.schema.json +456 -0
  97. scc_cli/schemas/team-config.v1.schema.json +163 -0
  98. scc_cli/services/__init__.py +1 -0
  99. scc_cli/services/git/__init__.py +79 -0
  100. scc_cli/services/git/branch.py +151 -0
  101. scc_cli/services/git/core.py +216 -0
  102. scc_cli/services/git/hooks.py +108 -0
  103. scc_cli/services/git/worktree.py +444 -0
  104. scc_cli/services/workspace/__init__.py +36 -0
  105. scc_cli/services/workspace/resolver.py +223 -0
  106. scc_cli/services/workspace/suspicious.py +200 -0
  107. scc_cli/sessions.py +425 -0
  108. scc_cli/setup.py +589 -0
  109. scc_cli/source_resolver.py +470 -0
  110. scc_cli/stats.py +378 -0
  111. scc_cli/stores/__init__.py +13 -0
  112. scc_cli/stores/exception_store.py +251 -0
  113. scc_cli/subprocess_utils.py +88 -0
  114. scc_cli/teams.py +383 -0
  115. scc_cli/templates/__init__.py +2 -0
  116. scc_cli/templates/org/__init__.py +0 -0
  117. scc_cli/templates/org/minimal.json +19 -0
  118. scc_cli/templates/org/reference.json +74 -0
  119. scc_cli/templates/org/strict.json +38 -0
  120. scc_cli/templates/org/teams.json +42 -0
  121. scc_cli/templates/statusline.sh +75 -0
  122. scc_cli/theme.py +348 -0
  123. scc_cli/ui/__init__.py +154 -0
  124. scc_cli/ui/branding.py +68 -0
  125. scc_cli/ui/chrome.py +401 -0
  126. scc_cli/ui/dashboard/__init__.py +62 -0
  127. scc_cli/ui/dashboard/_dashboard.py +794 -0
  128. scc_cli/ui/dashboard/loaders.py +452 -0
  129. scc_cli/ui/dashboard/models.py +185 -0
  130. scc_cli/ui/dashboard/orchestrator.py +735 -0
  131. scc_cli/ui/formatters.py +444 -0
  132. scc_cli/ui/gate.py +350 -0
  133. scc_cli/ui/git_interactive.py +869 -0
  134. scc_cli/ui/git_render.py +176 -0
  135. scc_cli/ui/help.py +157 -0
  136. scc_cli/ui/keys.py +615 -0
  137. scc_cli/ui/list_screen.py +437 -0
  138. scc_cli/ui/picker.py +763 -0
  139. scc_cli/ui/prompts.py +201 -0
  140. scc_cli/ui/quick_resume.py +116 -0
  141. scc_cli/ui/wizard.py +576 -0
  142. scc_cli/update.py +680 -0
  143. scc_cli/utils/__init__.py +39 -0
  144. scc_cli/utils/fixit.py +264 -0
  145. scc_cli/utils/fuzzy.py +124 -0
  146. scc_cli/utils/locks.py +114 -0
  147. scc_cli/utils/ttl.py +376 -0
  148. scc_cli/validate.py +455 -0
  149. scc_cli-1.5.3.dist-info/METADATA +401 -0
  150. scc_cli-1.5.3.dist-info/RECORD +153 -0
  151. scc_cli-1.5.3.dist-info/WHEEL +4 -0
  152. scc_cli-1.5.3.dist-info/entry_points.txt +2 -0
  153. scc_cli-1.5.3.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,114 @@
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 collections.abc import Generator
10
+ from contextlib import contextmanager
11
+ from io import TextIOWrapper
12
+ from pathlib import Path
13
+ from types import ModuleType
14
+ from typing import Any
15
+
16
+ fcntl: ModuleType | None
17
+ msvcrt: ModuleType | None
18
+
19
+ try:
20
+ import fcntl as _fcntl
21
+
22
+ fcntl = _fcntl
23
+ except ImportError: # pragma: no cover - Windows fallback
24
+ fcntl = None
25
+
26
+ try:
27
+ import msvcrt as _msvcrt
28
+
29
+ msvcrt = _msvcrt
30
+ except ImportError: # pragma: no cover - non-Windows fallback
31
+ msvcrt = None
32
+
33
+ LOCK_DIR = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "scc" / "locks"
34
+ DEFAULT_TIMEOUT = 5.0
35
+
36
+
37
+ def _normalize_key(key: str | Path) -> str:
38
+ path = Path(key).expanduser()
39
+ try:
40
+ return str(path.resolve(strict=False))
41
+ except OSError:
42
+ return str(path.absolute())
43
+
44
+
45
+ def lock_path(namespace: str, key: str | Path | None = None) -> Path:
46
+ """Return a stable lock file path for a namespace/key pair."""
47
+ LOCK_DIR.mkdir(parents=True, exist_ok=True)
48
+ if key is None:
49
+ filename = f"{namespace}.lock"
50
+ else:
51
+ digest = hashlib.sha256(_normalize_key(key).encode()).hexdigest()[:16]
52
+ filename = f"{namespace}-{digest}.lock"
53
+ return LOCK_DIR / filename
54
+
55
+
56
+ def _acquire_lock(lock_file: TextIOWrapper[Any]) -> None:
57
+ if fcntl is not None:
58
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
59
+ return
60
+ if msvcrt is not None:
61
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)
62
+ return
63
+
64
+
65
+ def _release_lock(lock_file: TextIOWrapper[Any]) -> None:
66
+ if fcntl is not None:
67
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
68
+ return
69
+ if msvcrt is not None:
70
+ try:
71
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
72
+ except OSError:
73
+ pass
74
+
75
+
76
+ @contextmanager
77
+ def file_lock(
78
+ path: Path, *, timeout: float = DEFAULT_TIMEOUT, poll: float = 0.1
79
+ ) -> Generator[None, None, None]:
80
+ """Acquire an exclusive file lock with a timeout."""
81
+ path.parent.mkdir(parents=True, exist_ok=True)
82
+ lock_file = path.open("a+")
83
+ acquired = False
84
+ start = time.monotonic()
85
+
86
+ try:
87
+ while True:
88
+ try:
89
+ _acquire_lock(lock_file)
90
+ acquired = True
91
+ break
92
+ except OSError as e:
93
+ if e.errno not in (errno.EACCES, errno.EAGAIN):
94
+ raise
95
+ except BlockingIOError:
96
+ pass
97
+
98
+ if time.monotonic() - start >= timeout:
99
+ raise TimeoutError(f"Timed out waiting for lock: {path}")
100
+ time.sleep(poll)
101
+
102
+ try:
103
+ lock_file.seek(0)
104
+ lock_file.truncate()
105
+ lock_file.write(str(os.getpid()))
106
+ lock_file.flush()
107
+ except OSError:
108
+ pass
109
+
110
+ yield
111
+ finally:
112
+ if acquired:
113
+ _release_lock(lock_file)
114
+ lock_file.close()