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.
- 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 +311 -0
- scc_cli/cli_common.py +190 -0
- scc_cli/cli_helpers.py +244 -0
- scc_cli/commands/__init__.py +20 -0
- scc_cli/commands/admin.py +708 -0
- scc_cli/commands/audit.py +246 -0
- scc_cli/commands/config.py +528 -0
- scc_cli/commands/exceptions.py +696 -0
- scc_cli/commands/init.py +272 -0
- scc_cli/commands/launch/__init__.py +73 -0
- scc_cli/commands/launch/app.py +1247 -0
- scc_cli/commands/launch/render.py +309 -0
- scc_cli/commands/launch/sandbox.py +135 -0
- scc_cli/commands/launch/workspace.py +339 -0
- scc_cli/commands/org/__init__.py +49 -0
- scc_cli/commands/org/_builders.py +264 -0
- scc_cli/commands/org/app.py +41 -0
- scc_cli/commands/org/import_cmd.py +267 -0
- scc_cli/commands/org/init_cmd.py +269 -0
- scc_cli/commands/org/schema_cmd.py +76 -0
- scc_cli/commands/org/status_cmd.py +157 -0
- scc_cli/commands/org/update_cmd.py +330 -0
- scc_cli/commands/org/validate_cmd.py +138 -0
- scc_cli/commands/support.py +323 -0
- scc_cli/commands/team.py +910 -0
- scc_cli/commands/worktree/__init__.py +72 -0
- scc_cli/commands/worktree/_helpers.py +57 -0
- scc_cli/commands/worktree/app.py +170 -0
- scc_cli/commands/worktree/container_commands.py +385 -0
- scc_cli/commands/worktree/context_commands.py +61 -0
- scc_cli/commands/worktree/session_commands.py +128 -0
- scc_cli/commands/worktree/worktree_commands.py +734 -0
- scc_cli/config.py +647 -0
- scc_cli/confirm.py +20 -0
- scc_cli/console.py +562 -0
- scc_cli/contexts.py +394 -0
- scc_cli/core/__init__.py +68 -0
- scc_cli/core/constants.py +101 -0
- scc_cli/core/errors.py +297 -0
- scc_cli/core/exit_codes.py +91 -0
- scc_cli/core/workspace.py +57 -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 +467 -0
- scc_cli/docker/credentials.py +726 -0
- scc_cli/docker/launch.py +595 -0
- scc_cli/doctor/__init__.py +105 -0
- scc_cli/doctor/checks/__init__.py +166 -0
- scc_cli/doctor/checks/cache.py +314 -0
- scc_cli/doctor/checks/config.py +107 -0
- scc_cli/doctor/checks/environment.py +182 -0
- scc_cli/doctor/checks/json_helpers.py +157 -0
- scc_cli/doctor/checks/organization.py +264 -0
- scc_cli/doctor/checks/worktree.py +278 -0
- scc_cli/doctor/render.py +365 -0
- scc_cli/doctor/types.py +66 -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/git.py +84 -0
- scc_cli/json_command.py +166 -0
- scc_cli/json_output.py +159 -0
- scc_cli/kinds.py +65 -0
- scc_cli/marketplace/__init__.py +123 -0
- scc_cli/marketplace/adapter.py +74 -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 +846 -0
- scc_cli/marketplace/normalize.py +548 -0
- scc_cli/marketplace/render.py +281 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +506 -0
- scc_cli/marketplace/sync.py +279 -0
- scc_cli/marketplace/team_cache.py +195 -0
- scc_cli/marketplace/team_fetch.py +689 -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 +960 -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/services/__init__.py +1 -0
- scc_cli/services/git/__init__.py +79 -0
- scc_cli/services/git/branch.py +151 -0
- scc_cli/services/git/core.py +216 -0
- scc_cli/services/git/hooks.py +108 -0
- scc_cli/services/git/worktree.py +444 -0
- scc_cli/services/workspace/__init__.py +36 -0
- scc_cli/services/workspace/resolver.py +223 -0
- scc_cli/services/workspace/suspicious.py +200 -0
- scc_cli/sessions.py +425 -0
- scc_cli/setup.py +589 -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 +383 -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 +154 -0
- scc_cli/ui/branding.py +68 -0
- scc_cli/ui/chrome.py +401 -0
- scc_cli/ui/dashboard/__init__.py +62 -0
- scc_cli/ui/dashboard/_dashboard.py +794 -0
- scc_cli/ui/dashboard/loaders.py +452 -0
- scc_cli/ui/dashboard/models.py +185 -0
- scc_cli/ui/dashboard/orchestrator.py +735 -0
- scc_cli/ui/formatters.py +444 -0
- scc_cli/ui/gate.py +350 -0
- scc_cli/ui/git_interactive.py +869 -0
- scc_cli/ui/git_render.py +176 -0
- scc_cli/ui/help.py +157 -0
- scc_cli/ui/keys.py +615 -0
- scc_cli/ui/list_screen.py +437 -0
- scc_cli/ui/picker.py +763 -0
- scc_cli/ui/prompts.py +201 -0
- scc_cli/ui/quick_resume.py +116 -0
- scc_cli/ui/wizard.py +576 -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 +114 -0
- scc_cli/utils/ttl.py +376 -0
- scc_cli/validate.py +455 -0
- scc_cli-1.5.3.dist-info/METADATA +401 -0
- scc_cli-1.5.3.dist-info/RECORD +153 -0
- scc_cli-1.5.3.dist-info/WHEEL +4 -0
- scc_cli-1.5.3.dist-info/entry_points.txt +2 -0
- scc_cli-1.5.3.dist-info/licenses/LICENSE +21 -0
scc_cli/utils/ttl.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Provide TTL parsing and expiration utilities for SCC Phase 2.1.
|
|
2
|
+
|
|
3
|
+
Handle parsing of time-bounded exception durations:
|
|
4
|
+
- TTL format: 30m, 2h, 8h, 1d
|
|
5
|
+
- RFC3339 timestamps: 2025-12-21T17:00:00+01:00
|
|
6
|
+
- Time-of-day: HH:MM (next occurrence)
|
|
7
|
+
|
|
8
|
+
Key behaviors:
|
|
9
|
+
- All internal timestamps are UTC
|
|
10
|
+
- --until uses local timezone, always schedules next occurrence
|
|
11
|
+
- DST edge cases (missing/ambiguous times) raise errors with guidance
|
|
12
|
+
- Hard max limit of 24h (configurable in future phases)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from datetime import date, datetime, timedelta, timezone
|
|
19
|
+
from zoneinfo import ZoneInfo
|
|
20
|
+
|
|
21
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
22
|
+
# Constants
|
|
23
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
24
|
+
|
|
25
|
+
DEFAULT_TTL = timedelta(hours=8)
|
|
26
|
+
MAX_TTL = timedelta(hours=24)
|
|
27
|
+
|
|
28
|
+
# Pattern for TTL format: 30m, 8h, 1d
|
|
29
|
+
TTL_PATTERN = re.compile(r"^(\d+)([mhdMHD])$")
|
|
30
|
+
|
|
31
|
+
# Pattern for HH:MM format
|
|
32
|
+
UNTIL_PATTERN = re.compile(r"^(\d{2}):(\d{2})$")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
36
|
+
# Internal Helpers (for test mocking)
|
|
37
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _get_now() -> datetime:
|
|
41
|
+
"""Get current UTC time. Extracted for test mocking."""
|
|
42
|
+
return datetime.now(timezone.utc)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _get_local_tz() -> ZoneInfo | timezone:
|
|
46
|
+
"""Get local timezone. Extracted for test mocking."""
|
|
47
|
+
try:
|
|
48
|
+
import os
|
|
49
|
+
|
|
50
|
+
# Try to get proper timezone from TZ environment variable
|
|
51
|
+
tz_env = os.environ.get("TZ")
|
|
52
|
+
if tz_env:
|
|
53
|
+
return ZoneInfo(tz_env)
|
|
54
|
+
# Fallback: use UTC offset from system
|
|
55
|
+
local_offset = datetime.now().astimezone().utcoffset()
|
|
56
|
+
if local_offset is not None:
|
|
57
|
+
return timezone(local_offset)
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
return timezone.utc
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
64
|
+
# TTL Parsing (--ttl)
|
|
65
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_ttl(ttl_string: str) -> timedelta:
|
|
69
|
+
"""Parse a TTL duration string like '30m', '8h', or '1d'.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
ttl_string: Duration in format like "30m", "8h", "1d"
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
timedelta representing the duration
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
ValueError: If format is invalid or duration is non-positive
|
|
79
|
+
"""
|
|
80
|
+
if not ttl_string:
|
|
81
|
+
raise ValueError("TTL cannot be empty")
|
|
82
|
+
|
|
83
|
+
match = TTL_PATTERN.match(ttl_string)
|
|
84
|
+
if not match:
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"Invalid TTL '{ttl_string}'. "
|
|
87
|
+
f"Use: --ttl 8h, --ttl 30m, or --expires-at 2025-12-21T17:00:00+01:00"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
value = int(match.group(1))
|
|
91
|
+
unit = match.group(2).lower()
|
|
92
|
+
|
|
93
|
+
if value <= 0:
|
|
94
|
+
raise ValueError("TTL must be positive")
|
|
95
|
+
|
|
96
|
+
if unit == "m":
|
|
97
|
+
return timedelta(minutes=value)
|
|
98
|
+
elif unit == "h":
|
|
99
|
+
return timedelta(hours=value)
|
|
100
|
+
elif unit == "d":
|
|
101
|
+
return timedelta(days=value)
|
|
102
|
+
else:
|
|
103
|
+
raise ValueError(f"Unknown TTL unit: {unit}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def validate_ttl_duration(duration: timedelta) -> None:
|
|
107
|
+
"""Validate that a TTL duration is within allowed limits.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
duration: The duration to validate
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
ValueError: If duration exceeds MAX_TTL
|
|
114
|
+
"""
|
|
115
|
+
if duration > MAX_TTL:
|
|
116
|
+
max_hours = int(MAX_TTL.total_seconds() // 3600)
|
|
117
|
+
raise ValueError(f"TTL exceeds maximum allowed duration of {max_hours} hours")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
121
|
+
# RFC3339 Parsing (--expires-at)
|
|
122
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def parse_expires_at(timestamp: str) -> datetime:
|
|
126
|
+
"""Parse an RFC3339 timestamp for expiration.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
timestamp: RFC3339 formatted timestamp like "2025-12-21T17:00:00Z"
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
datetime with timezone info (converted to UTC internally)
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
ValueError: If format is invalid or time is in the past
|
|
136
|
+
"""
|
|
137
|
+
try:
|
|
138
|
+
# Handle Z suffix (UTC)
|
|
139
|
+
if timestamp.endswith("Z"):
|
|
140
|
+
timestamp = timestamp[:-1] + "+00:00"
|
|
141
|
+
|
|
142
|
+
dt = datetime.fromisoformat(timestamp)
|
|
143
|
+
|
|
144
|
+
# Ensure timezone-aware
|
|
145
|
+
if dt.tzinfo is None:
|
|
146
|
+
raise ValueError(
|
|
147
|
+
"Timestamp must include timezone. "
|
|
148
|
+
"Format: --expires-at 2025-12-21T17:00:00+01:00 or 2025-12-21T17:00:00Z"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
except ValueError as e:
|
|
152
|
+
if "Invalid isoformat" in str(e) or "fromisoformat" in str(e):
|
|
153
|
+
raise ValueError(
|
|
154
|
+
"Invalid timestamp format. Use RFC3339: --expires-at 2025-12-21T17:00:00+01:00"
|
|
155
|
+
) from e
|
|
156
|
+
raise
|
|
157
|
+
|
|
158
|
+
# Check not in the past
|
|
159
|
+
now = _get_now()
|
|
160
|
+
if dt.astimezone(timezone.utc) <= now:
|
|
161
|
+
raise ValueError("Expiration time is in the past. Provide a future timestamp.")
|
|
162
|
+
|
|
163
|
+
return dt.astimezone(timezone.utc)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
167
|
+
# Time-of-Day Parsing (--until)
|
|
168
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def parse_until(time_string: str) -> datetime:
|
|
172
|
+
"""Parse a time-of-day string like '17:00' for next occurrence.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
time_string: Time in HH:MM format
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
datetime (in local timezone) for next occurrence of that time
|
|
179
|
+
|
|
180
|
+
Raises:
|
|
181
|
+
ValueError: If format is invalid, time doesn't exist (DST spring-forward),
|
|
182
|
+
or time is ambiguous (DST fall-back)
|
|
183
|
+
"""
|
|
184
|
+
match = UNTIL_PATTERN.match(time_string)
|
|
185
|
+
if not match:
|
|
186
|
+
raise ValueError(
|
|
187
|
+
f"Invalid time format '{time_string}'. Use HH:MM format like: --until 17:00"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
hour = int(match.group(1))
|
|
191
|
+
minute = int(match.group(2))
|
|
192
|
+
|
|
193
|
+
if hour > 23:
|
|
194
|
+
raise ValueError(f"Invalid hour: {hour}. Must be 00-23.")
|
|
195
|
+
if minute > 59:
|
|
196
|
+
raise ValueError(f"Invalid minute: {minute}. Must be 00-59.")
|
|
197
|
+
|
|
198
|
+
now = _get_now()
|
|
199
|
+
local_tz = _get_local_tz()
|
|
200
|
+
|
|
201
|
+
# Convert current time to local timezone
|
|
202
|
+
now_local = now.astimezone(local_tz)
|
|
203
|
+
|
|
204
|
+
# Start with today's date at the specified time
|
|
205
|
+
target_date = now_local.date()
|
|
206
|
+
target_time_today = _make_local_datetime(target_date, hour, minute, local_tz)
|
|
207
|
+
|
|
208
|
+
# If time has passed or is exactly now, schedule for tomorrow
|
|
209
|
+
if target_time_today is None or target_time_today <= now_local:
|
|
210
|
+
tomorrow = target_date + timedelta(days=1)
|
|
211
|
+
target_time_tomorrow = _make_local_datetime(tomorrow, hour, minute, local_tz)
|
|
212
|
+
if target_time_tomorrow is None:
|
|
213
|
+
raise ValueError(
|
|
214
|
+
f"Time {time_string} does not exist tomorrow due to DST transition. "
|
|
215
|
+
f"Use --expires-at with explicit UTC offset instead."
|
|
216
|
+
)
|
|
217
|
+
return target_time_tomorrow.astimezone(timezone.utc)
|
|
218
|
+
|
|
219
|
+
return target_time_today.astimezone(timezone.utc)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _make_local_datetime(
|
|
223
|
+
target_date: date, hour: int, minute: int, tz: ZoneInfo | timezone
|
|
224
|
+
) -> datetime | None:
|
|
225
|
+
"""Create a datetime in local timezone, handling DST edge cases.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
datetime if valid, None if time doesn't exist (spring-forward)
|
|
229
|
+
|
|
230
|
+
Raises:
|
|
231
|
+
ValueError: If time is ambiguous (fall-back)
|
|
232
|
+
"""
|
|
233
|
+
try:
|
|
234
|
+
# Create naive datetime first
|
|
235
|
+
naive_dt = datetime(target_date.year, target_date.month, target_date.day, hour, minute, 0)
|
|
236
|
+
|
|
237
|
+
# For ZoneInfo timezones, check for DST issues
|
|
238
|
+
if isinstance(tz, ZoneInfo):
|
|
239
|
+
# Try to localize - this can raise for ambiguous times
|
|
240
|
+
try:
|
|
241
|
+
# Use fold=0 first, then check if fold=1 gives different result
|
|
242
|
+
dt_fold0 = naive_dt.replace(tzinfo=tz, fold=0)
|
|
243
|
+
dt_fold1 = naive_dt.replace(tzinfo=tz, fold=1)
|
|
244
|
+
|
|
245
|
+
# If the UTC offsets differ, time is ambiguous (fall-back)
|
|
246
|
+
if dt_fold0.utcoffset() != dt_fold1.utcoffset():
|
|
247
|
+
raise ValueError(
|
|
248
|
+
f"Time {hour:02d}:{minute:02d} is ambiguous due to DST transition. "
|
|
249
|
+
f"Use --expires-at with explicit UTC offset instead."
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Check if time exists (spring-forward case)
|
|
253
|
+
# After localization, convert back to naive and check if it matches
|
|
254
|
+
utc_time = dt_fold0.astimezone(timezone.utc)
|
|
255
|
+
back_to_local = utc_time.astimezone(tz)
|
|
256
|
+
if back_to_local.hour != hour or back_to_local.minute != minute:
|
|
257
|
+
# Time doesn't exist (was skipped in spring-forward)
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
return dt_fold0
|
|
261
|
+
|
|
262
|
+
except Exception as e:
|
|
263
|
+
if "ambiguous" in str(e).lower():
|
|
264
|
+
raise ValueError(
|
|
265
|
+
f"Time {hour:02d}:{minute:02d} is ambiguous due to DST transition. "
|
|
266
|
+
f"Use --expires-at with explicit UTC offset instead."
|
|
267
|
+
) from e
|
|
268
|
+
raise
|
|
269
|
+
else:
|
|
270
|
+
# Simple timezone (like UTC or fixed offset)
|
|
271
|
+
return naive_dt.replace(tzinfo=tz)
|
|
272
|
+
|
|
273
|
+
except ValueError:
|
|
274
|
+
raise
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
278
|
+
# Expiration Calculation
|
|
279
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def calculate_expiration(
|
|
283
|
+
ttl: str | None = None,
|
|
284
|
+
expires_at: str | None = None,
|
|
285
|
+
until: str | None = None,
|
|
286
|
+
) -> datetime:
|
|
287
|
+
"""Calculate expiration datetime from one of the timing options.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
ttl: Duration string like "8h"
|
|
291
|
+
expires_at: RFC3339 timestamp
|
|
292
|
+
until: Time-of-day like "17:00"
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
datetime in UTC for expiration
|
|
296
|
+
|
|
297
|
+
Raises:
|
|
298
|
+
ValueError: If multiple options specified (mutually exclusive)
|
|
299
|
+
"""
|
|
300
|
+
# Count how many options were provided
|
|
301
|
+
provided = sum(1 for opt in [ttl, expires_at, until] if opt is not None)
|
|
302
|
+
|
|
303
|
+
if provided > 1:
|
|
304
|
+
raise ValueError(
|
|
305
|
+
"Only one of --ttl, --expires-at, or --until can be specified. "
|
|
306
|
+
"These options are mutually exclusive."
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
if ttl is not None:
|
|
310
|
+
duration = parse_ttl(ttl)
|
|
311
|
+
validate_ttl_duration(duration)
|
|
312
|
+
return _get_now() + duration
|
|
313
|
+
|
|
314
|
+
if expires_at is not None:
|
|
315
|
+
return parse_expires_at(expires_at)
|
|
316
|
+
|
|
317
|
+
if until is not None:
|
|
318
|
+
return parse_until(until)
|
|
319
|
+
|
|
320
|
+
# Default: use DEFAULT_TTL
|
|
321
|
+
return _get_now() + DEFAULT_TTL
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
325
|
+
# Formatting
|
|
326
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def format_expiration(dt: datetime) -> str:
|
|
330
|
+
"""Format a datetime as RFC3339 string.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
dt: datetime to format (should be timezone-aware)
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
RFC3339 formatted string like "2025-12-21T17:00:00Z"
|
|
337
|
+
"""
|
|
338
|
+
# Convert to UTC and format with Z suffix
|
|
339
|
+
utc_dt = dt.astimezone(timezone.utc)
|
|
340
|
+
return utc_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def format_relative(expires: datetime) -> str:
|
|
344
|
+
"""Format remaining time until expiration as human-readable string.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
expires: expiration datetime
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
String like "7h45m", "30m", "1d", or "expired"
|
|
351
|
+
"""
|
|
352
|
+
now = _get_now()
|
|
353
|
+
delta = expires - now
|
|
354
|
+
|
|
355
|
+
if delta.total_seconds() <= 0:
|
|
356
|
+
return "expired"
|
|
357
|
+
|
|
358
|
+
total_seconds = int(delta.total_seconds())
|
|
359
|
+
days = total_seconds // 86400
|
|
360
|
+
hours = (total_seconds % 86400) // 3600
|
|
361
|
+
minutes = (total_seconds % 3600) // 60
|
|
362
|
+
|
|
363
|
+
if days > 0:
|
|
364
|
+
if hours == 0 and minutes == 0:
|
|
365
|
+
return f"{days}d"
|
|
366
|
+
elif hours > 0:
|
|
367
|
+
return f"{days}d{hours}h"
|
|
368
|
+
else:
|
|
369
|
+
return f"{days}d{minutes}m"
|
|
370
|
+
|
|
371
|
+
if hours > 0:
|
|
372
|
+
if minutes > 0:
|
|
373
|
+
return f"{hours}h{minutes}m"
|
|
374
|
+
return f"{hours}h"
|
|
375
|
+
|
|
376
|
+
return f"{minutes}m"
|