cartha-cli 1.0.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.
cartha_cli/main.py ADDED
@@ -0,0 +1,237 @@
1
+ """Primary Typer application for the Cartha CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from .commands import (
8
+ config,
9
+ health,
10
+ miner_status,
11
+ pair_status,
12
+ pools,
13
+ prove_lock,
14
+ register,
15
+ version,
16
+ )
17
+ from .commands.common import log_endpoint_banner, set_trace_enabled
18
+ from .commands.help import print_root_help
19
+
20
+ app = typer.Typer(
21
+ help="Miner-facing tooling for registering on the Cartha subnet and managing lock positions. Cartha is the Liquidity Provider for 0xMarkets DEX.",
22
+ add_completion=False,
23
+ )
24
+
25
+ # Create command groups
26
+ miner_app = typer.Typer(
27
+ help="Miner management commands: register, check status, and manage passwords.",
28
+ name="miner",
29
+ invoke_without_command=True,
30
+ )
31
+ miner_app_alias = typer.Typer(
32
+ help="Miner management commands: register, check status, and manage passwords.",
33
+ name="m",
34
+ invoke_without_command=True,
35
+ )
36
+ vault_app = typer.Typer(
37
+ help="Vault management commands: lock funds and claim deposits.",
38
+ name="vault",
39
+ invoke_without_command=True,
40
+ )
41
+ vault_app_alias = typer.Typer(
42
+ help="Vault management commands: lock funds and claim deposits.",
43
+ name="v",
44
+ invoke_without_command=True,
45
+ )
46
+
47
+
48
+ # Define callbacks for groups (show help when invoked without subcommand)
49
+ def miner_group_callback(
50
+ ctx: typer.Context,
51
+ help_option: bool = typer.Option(
52
+ False,
53
+ "--help",
54
+ "-h",
55
+ help="Show this message and exit.",
56
+ is_eager=True,
57
+ ),
58
+ ) -> None:
59
+ """Miner management commands."""
60
+ if ctx.invoked_subcommand is None or help_option:
61
+ ctx.get_help()
62
+ raise typer.Exit()
63
+
64
+
65
+ def vault_group_callback(
66
+ ctx: typer.Context,
67
+ help_option: bool = typer.Option(
68
+ False,
69
+ "--help",
70
+ "-h",
71
+ help="Show this message and exit.",
72
+ is_eager=True,
73
+ ),
74
+ ) -> None:
75
+ """Vault management commands."""
76
+ if ctx.invoked_subcommand is None or help_option:
77
+ ctx.get_help()
78
+ raise typer.Exit()
79
+
80
+
81
+ # Register callbacks for both miner apps (main and alias)
82
+ miner_app.callback(invoke_without_command=True)(miner_group_callback)
83
+ miner_app_alias.callback(invoke_without_command=True)(miner_group_callback)
84
+
85
+ # Register callbacks for both vault apps (main and alias)
86
+ vault_app.callback(invoke_without_command=True)(vault_group_callback)
87
+ vault_app_alias.callback(invoke_without_command=True)(vault_group_callback)
88
+
89
+ # Register commands in both miner apps (main and alias)
90
+ for miner_group in [miner_app, miner_app_alias]:
91
+ miner_group.command("status")(miner_status.miner_status)
92
+ miner_group.command("register")(register.register)
93
+
94
+ # Register commands in both vault apps (main and alias)
95
+ for vault_group in [vault_app, vault_app_alias]:
96
+ vault_group.command("lock")(prove_lock.prove_lock)
97
+ vault_group.command("pools")(pools.pools)
98
+
99
+ # Add groups with short aliases (after callbacks and commands are registered)
100
+ app.add_typer(miner_app, name="miner")
101
+ app.add_typer(miner_app_alias, name="m") # Short alias
102
+ app.add_typer(vault_app, name="vault")
103
+ app.add_typer(vault_app_alias, name="v") # Short alias
104
+
105
+ # Keep pair_app for backward compatibility (deprecated)
106
+ pair_app = typer.Typer(
107
+ help="Pair status commands (deprecated - use 'cartha miner status')."
108
+ )
109
+ app.add_typer(pair_app, name="pair")
110
+
111
+
112
+ @app.callback(invoke_without_command=True)
113
+ def cli_root(
114
+ ctx: typer.Context,
115
+ help_option: bool = typer.Option(
116
+ False,
117
+ "--help",
118
+ "-h",
119
+ help="Show this message and exit.",
120
+ is_eager=True,
121
+ ),
122
+ trace: bool = typer.Option(
123
+ False,
124
+ "--trace",
125
+ help="Show full stack traces when errors occur.",
126
+ ),
127
+ ) -> None:
128
+ """Top-level callback to provide rich help and endpoint banner."""
129
+ set_trace_enabled(trace)
130
+ if ctx.obj is None:
131
+ ctx.obj = {}
132
+ ctx.obj["trace"] = trace
133
+
134
+ if help_option:
135
+ print_root_help()
136
+ raise typer.Exit()
137
+
138
+ if ctx.invoked_subcommand is None:
139
+ print_root_help()
140
+ raise typer.Exit()
141
+
142
+ log_endpoint_banner()
143
+
144
+
145
+ # Register top-level commands
146
+ app.command("version")(version.version_command)
147
+
148
+ # Create utils command group
149
+ utils_app = typer.Typer(
150
+ help="Utility commands: health checks and configuration management.",
151
+ name="utils",
152
+ invoke_without_command=True,
153
+ )
154
+ utils_app_alias = typer.Typer(
155
+ help="Utility commands: health checks and configuration management.",
156
+ name="u",
157
+ invoke_without_command=True,
158
+ )
159
+
160
+ def utils_group_callback(
161
+ ctx: typer.Context,
162
+ help_option: bool = typer.Option(
163
+ False,
164
+ "--help",
165
+ "-h",
166
+ help="Show this message and exit.",
167
+ is_eager=True,
168
+ ),
169
+ ) -> None:
170
+ """Utility commands."""
171
+ if ctx.invoked_subcommand is None or help_option:
172
+ ctx.get_help()
173
+ raise typer.Exit()
174
+
175
+ # Register callbacks for both utils apps (main and alias)
176
+ utils_app.callback(invoke_without_command=True)(utils_group_callback)
177
+ utils_app_alias.callback(invoke_without_command=True)(utils_group_callback)
178
+
179
+ # Register commands in both utils apps (main and alias)
180
+ for utils_group in [utils_app, utils_app_alias]:
181
+ utils_group.command("health")(health.health_check)
182
+
183
+ # Create config subcommand group under utils
184
+ config_app = typer.Typer(
185
+ help="Configuration commands: view and set environment variables.",
186
+ name="config",
187
+ invoke_without_command=True,
188
+ )
189
+
190
+ def config_group_callback(
191
+ ctx: typer.Context,
192
+ help_option: bool = typer.Option(
193
+ False,
194
+ "--help",
195
+ "-h",
196
+ help="Show this message and exit.",
197
+ is_eager=True,
198
+ ),
199
+ ) -> None:
200
+ """Configuration commands."""
201
+ if ctx.invoked_subcommand is None or help_option:
202
+ if ctx.invoked_subcommand is None:
203
+ # Show list by default
204
+ config.config_list()
205
+ else:
206
+ ctx.get_help()
207
+ raise typer.Exit()
208
+
209
+ config_app.callback(invoke_without_command=True)(config_group_callback)
210
+ config_app.command("list")(config.config_list)
211
+ config_app.command("set")(config.config_set)
212
+ config_app.command("get")(config.config_get)
213
+ config_app.command("unset")(config.config_unset)
214
+
215
+ # Add config to both utils apps (main and alias)
216
+ for utils_group in [utils_app, utils_app_alias]:
217
+ utils_group.add_typer(config_app)
218
+
219
+ # Add groups with short aliases (after callbacks and commands are registered)
220
+ app.add_typer(utils_app, name="utils")
221
+ app.add_typer(utils_app_alias, name="u") # Short alias
222
+
223
+
224
+ def help_command() -> None:
225
+ """Show help message."""
226
+ print_root_help()
227
+ raise typer.Exit()
228
+
229
+
230
+ app.command("help")(help_command)
231
+
232
+ # Keep deprecated commands for backward compatibility
233
+ pair_app.command("status")(pair_status.pair_status)
234
+
235
+
236
+ if __name__ == "__main__": # pragma: no cover
237
+ app()
cartha_cli/pair.py ADDED
@@ -0,0 +1,201 @@
1
+ """Pair authentication and status utilities for the Cartha CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+
8
+ import bittensor as bt
9
+ import typer
10
+ from rich.console import Console
11
+
12
+ from .bt import get_subtensor
13
+ from .utils import format_timestamp
14
+ from .verifier import VerifierError, fetch_pair_status
15
+ from .wallet import CHALLENGE_PREFIX, CHALLENGE_TTL_SECONDS, load_wallet
16
+
17
+ console = Console()
18
+
19
+
20
+ def get_uid_from_hotkey(
21
+ *,
22
+ network: str,
23
+ netuid: int,
24
+ hotkey: str,
25
+ ) -> int | None:
26
+ """Get the UID for a hotkey on the subnet.
27
+
28
+ Args:
29
+ network: Bittensor network name
30
+ netuid: Subnet netuid
31
+ hotkey: Hotkey SS58 address
32
+
33
+ Returns:
34
+ UID if registered, None if not registered or deregistered
35
+ """
36
+ subtensor = None
37
+
38
+ try:
39
+ subtensor = get_subtensor(network)
40
+
41
+ # Try to get UID directly - if successful, they're registered
42
+ try:
43
+ uid = subtensor.get_uid_for_hotkey_on_subnet(
44
+ hotkey_ss58=hotkey, netuid=netuid
45
+ )
46
+ if uid is not None and uid >= 0:
47
+ return int(uid)
48
+ except AttributeError:
49
+ # Method doesn't exist in this bittensor version
50
+ return None
51
+ except Exception:
52
+ # Any other error means not registered or network issue
53
+ return None
54
+
55
+ return None
56
+ except Exception as exc:
57
+ error_msg = str(exc)
58
+ if "nodename" in error_msg.lower() or "servname" in error_msg.lower():
59
+ console.print(
60
+ f"[bold red]Network error[/]: Unable to connect to Bittensor {network} network: {error_msg}"
61
+ )
62
+ console.print(
63
+ "[yellow]This might be a DNS/network connectivity issue. Please check your internet connection.[/]"
64
+ )
65
+ raise typer.Exit(code=1) from None
66
+ # Re-raise other exceptions as-is
67
+ raise
68
+ finally:
69
+ # Clean up connections
70
+ try:
71
+ if subtensor is not None:
72
+ if hasattr(subtensor, "close"):
73
+ subtensor.close()
74
+ del subtensor
75
+ except Exception:
76
+ pass
77
+
78
+
79
+ def ensure_pair_registered(
80
+ *,
81
+ network: str,
82
+ netuid: int,
83
+ slot: str,
84
+ hotkey: str,
85
+ ) -> None:
86
+ """Ensure a pair is registered on the subnet.
87
+
88
+ Args:
89
+ network: Bittensor network name
90
+ netuid: Subnet netuid
91
+ slot: Slot UID
92
+ hotkey: Hotkey SS58 address
93
+
94
+ Raises:
95
+ typer.Exit: If pair is not registered or UID mismatch
96
+ """
97
+ subtensor = None
98
+ metagraph = None
99
+ try:
100
+ subtensor = get_subtensor(network)
101
+ metagraph = subtensor.metagraph(netuid)
102
+ slot_index = int(slot)
103
+ if slot_index < 0 or slot_index >= len(metagraph.hotkeys):
104
+ console.print(
105
+ f"[bold red]UID {slot} not found[/] in the metagraph (netuid {netuid})."
106
+ )
107
+ raise typer.Exit(code=1)
108
+ registered_hotkey = metagraph.hotkeys[slot_index]
109
+ if registered_hotkey != hotkey:
110
+ console.print(
111
+ f"[bold red]UID mismatch[/]: slot {slot} belongs to a different hotkey, not {hotkey}. Please verify your inputs."
112
+ )
113
+ raise typer.Exit(code=1)
114
+ except Exception as exc:
115
+ error_msg = str(exc)
116
+ if "nodename" in error_msg.lower() or "servname" in error_msg.lower():
117
+ console.print(
118
+ f"[bold red]Network error[/]: Unable to connect to Bittensor {network} network: {error_msg}"
119
+ )
120
+ console.print(
121
+ "[yellow]This might be a DNS/network connectivity issue. Please check your internet connection.[/]"
122
+ )
123
+ raise typer.Exit(code=1) from None
124
+ # Re-raise other exceptions as-is
125
+ raise
126
+ finally:
127
+ # Clean up connections
128
+ try:
129
+ if subtensor is not None:
130
+ if hasattr(subtensor, "close"):
131
+ subtensor.close()
132
+ del subtensor
133
+ if metagraph is not None:
134
+ del metagraph
135
+ except Exception:
136
+ pass
137
+
138
+
139
+ def build_pair_auth_payload(
140
+ *,
141
+ network: str,
142
+ netuid: int,
143
+ slot: str,
144
+ hotkey: str,
145
+ wallet_name: str,
146
+ wallet_hotkey: str,
147
+ skip_metagraph_check: bool = False,
148
+ challenge_prefix: str | None = None,
149
+ ) -> dict[str, Any]:
150
+ """Build authentication payload for pair status/password requests or lock flow.
151
+
152
+ Args:
153
+ network: Bittensor network name
154
+ netuid: Subnet netuid
155
+ slot: Slot UID
156
+ hotkey: Hotkey SS58 address
157
+ wallet_name: Coldkey wallet name
158
+ wallet_hotkey: Hotkey name
159
+ skip_metagraph_check: Skip metagraph validation check
160
+ challenge_prefix: Challenge prefix (defaults to CHALLENGE_PREFIX, use "cartha-lock" for lock flow)
161
+
162
+ Returns:
163
+ Dictionary with message, signature, and expires_at
164
+ """
165
+ wallet = load_wallet(wallet_name, wallet_hotkey, hotkey)
166
+ if not skip_metagraph_check:
167
+ ensure_pair_registered(
168
+ network=network, netuid=netuid, slot=slot, hotkey=hotkey
169
+ )
170
+
171
+ timestamp = int(time.time())
172
+ prefix = challenge_prefix or CHALLENGE_PREFIX
173
+ message = (
174
+ f"{prefix}|network:{network}|netuid:{netuid}|slot:{slot}|"
175
+ f"hotkey:{hotkey}|ts:{timestamp}"
176
+ )
177
+ message_bytes = message.encode("utf-8")
178
+ signature_bytes = wallet.hotkey.sign(message_bytes)
179
+
180
+ verifier_keypair = bt.Keypair(ss58_address=hotkey)
181
+ if not verifier_keypair.verify(message_bytes, signature_bytes):
182
+ console.print("[bold red]Unable to verify the ownership signature locally.[/]")
183
+ raise typer.Exit(code=1)
184
+
185
+ expires_at = timestamp + CHALLENGE_TTL_SECONDS
186
+ expiry_time = format_timestamp(expires_at)
187
+ console.print(
188
+ "[bold green]Ownership challenge signed[/] "
189
+ f"(expires in {CHALLENGE_TTL_SECONDS}s at {expiry_time})."
190
+ )
191
+
192
+ return {
193
+ "message": message,
194
+ "signature": "0x" + signature_bytes.hex(),
195
+ "expires_at": expires_at,
196
+ }
197
+
198
+
199
+ # REMOVED: request_pair_status_or_password - replaced by new lock flow
200
+ # Old function removed as part of new lock flow implementation
201
+
cartha_cli/utils.py ADDED
@@ -0,0 +1,274 @@
1
+ """Utility functions for the Cartha CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from datetime import UTC, datetime, timedelta
7
+ from decimal import ROUND_DOWN, Decimal, InvalidOperation
8
+ from zoneinfo import ZoneInfo
9
+
10
+ import typer
11
+ from rich import box
12
+ from rich.table import Table
13
+
14
+ from .config import settings
15
+
16
+ console = typer.get_current().console if hasattr(typer, "get_current") else None
17
+
18
+
19
+ def normalize_hex(value: str, prefix: str = "0x") -> str:
20
+ """Normalize hex string to ensure it has the correct prefix.
21
+
22
+ Handles common mistakes like "Ox" (capital O) -> "0x" (zero).
23
+ """
24
+ value = value.strip()
25
+ # Fix common mistake: "Ox" (capital O) -> "0x" (zero)
26
+ if value.startswith("Ox") or value.startswith("OX"):
27
+ value = "0x" + value[2:]
28
+ if not value.startswith(prefix):
29
+ value = prefix + value
30
+ return value
31
+
32
+
33
+ def format_timestamp(ts: int | float | str | None) -> str:
34
+ """Format a timestamp showing both UTC and local time.
35
+
36
+ Args:
37
+ ts: Unix timestamp (seconds) as int, float, or string, or None for current time
38
+
39
+ Returns:
40
+ Formatted string like "2024-01-01 12:00:00 UTC (2024-01-01 07:00:00 EST)"
41
+ """
42
+ if ts is None:
43
+ ts = time.time()
44
+ elif isinstance(ts, str):
45
+ try:
46
+ ts = float(ts)
47
+ except ValueError:
48
+ return str(ts) # Return as-is if not parseable
49
+
50
+ try:
51
+ ts_float = float(ts)
52
+ utc_dt = datetime.fromtimestamp(ts_float, tz=UTC)
53
+
54
+ # Get local timezone
55
+ try:
56
+ local_tz: ZoneInfo = ZoneInfo("local")
57
+ except Exception:
58
+ # Fallback if zoneinfo fails (shouldn't happen on Python 3.11+)
59
+ fallback_tz = datetime.now().astimezone().tzinfo
60
+ if fallback_tz is None:
61
+ # Ultimate fallback to UTC
62
+ local_tz = ZoneInfo("UTC")
63
+ else:
64
+ # Type ignore: mypy doesn't understand that ZoneInfo is compatible with tzinfo
65
+ local_tz = fallback_tz # type: ignore[assignment]
66
+
67
+ local_dt = utc_dt.astimezone(local_tz)
68
+
69
+ # Format both times
70
+ utc_str = utc_dt.strftime("%Y-%m-%d %H:%M:%S UTC")
71
+ local_str = local_dt.strftime("%Y-%m-%d %H:%M:%S %Z")
72
+
73
+ return f"{utc_str} ({local_str})"
74
+ except (ValueError, OSError, OverflowError):
75
+ # Fallback to simple ISO format if anything fails
76
+ try:
77
+ return datetime.fromtimestamp(float(ts), tz=UTC).isoformat()
78
+ except Exception:
79
+ return str(ts)
80
+
81
+
82
+ def format_timestamp_multiline(ts: int | float | str | None) -> str:
83
+ """Format a timestamp showing UTC and local time on separate lines.
84
+
85
+ Args:
86
+ ts: Unix timestamp (seconds) as int, float, or string, or None for current time
87
+
88
+ Returns:
89
+ Formatted string with UTC on first line, local time on second line (if different from UTC),
90
+ or just UTC if user is in UTC timezone.
91
+ """
92
+ if ts is None:
93
+ ts = time.time()
94
+ elif isinstance(ts, str):
95
+ try:
96
+ ts = float(ts)
97
+ except ValueError:
98
+ return str(ts) # Return as-is if not parseable
99
+
100
+ try:
101
+ ts_float = float(ts)
102
+ utc_dt = datetime.fromtimestamp(ts_float, tz=UTC)
103
+
104
+ # Get local timezone
105
+ try:
106
+ local_tz: ZoneInfo = ZoneInfo("local")
107
+ except Exception:
108
+ fallback_tz = datetime.now().astimezone().tzinfo
109
+ if fallback_tz is None:
110
+ local_tz = ZoneInfo("UTC")
111
+ else:
112
+ local_tz = fallback_tz # type: ignore[assignment]
113
+
114
+ local_dt = utc_dt.astimezone(local_tz)
115
+
116
+ # Format UTC time
117
+ utc_str = utc_dt.strftime("%Y-%m-%d %H:%M:%S UTC")
118
+
119
+ # Check if local timezone is UTC (compare timezone objects, not just hours/minutes)
120
+ # Also check if the offset is zero (handles cases where timezone name differs but offset is UTC)
121
+ is_utc_timezone = (
122
+ str(local_tz) == "UTC"
123
+ or local_tz.utcoffset(utc_dt) == timedelta(0)
124
+ or (hasattr(local_tz, 'key') and local_tz.key == 'UTC')
125
+ )
126
+
127
+ if is_utc_timezone:
128
+ # User is in UTC timezone, only show UTC
129
+ return utc_str
130
+ else:
131
+ # Show both UTC and local time on separate lines
132
+ local_str = local_dt.strftime("%Y-%m-%d %H:%M:%S %Z")
133
+ return f"{utc_str}\n({local_str})"
134
+ except (ValueError, OSError, OverflowError):
135
+ try:
136
+ return datetime.fromtimestamp(float(ts), tz=UTC).isoformat()
137
+ except Exception:
138
+ return str(ts)
139
+
140
+
141
+ def format_evm_address(address: str) -> str:
142
+ """Format an EVM address in standard crypto wallet display format.
143
+
144
+ Args:
145
+ address: EVM address (e.g., "0x86997f52073317659B25aA622C5d93ed77444DeE")
146
+
147
+ Returns:
148
+ Formatted address like "0x8699...44DeE" (first 6 chars after 0x + last 4 chars)
149
+ """
150
+ if not address or len(address) < 10:
151
+ return address
152
+
153
+ # Remove 0x prefix if present for processing
154
+ if address.startswith("0x"):
155
+ prefix = "0x"
156
+ addr_without_prefix = address[2:]
157
+ else:
158
+ prefix = ""
159
+ addr_without_prefix = address
160
+
161
+ if len(addr_without_prefix) <= 10:
162
+ # Address is too short, return as-is
163
+ return address
164
+
165
+ # Format: 0x + first 4 chars + ... + last 4 chars
166
+ return f"{prefix}{addr_without_prefix[:4]}...{addr_without_prefix[-4:]}"
167
+
168
+
169
+ def usdc_to_base_units(value: str) -> int:
170
+ """Convert USDC amount string to base units (micro-USDC).
171
+
172
+ Args:
173
+ value: USDC amount as string (e.g., "250.5")
174
+
175
+ Returns:
176
+ Amount in base units (e.g., 250500000)
177
+
178
+ Raises:
179
+ typer.Exit: If value is invalid or non-positive
180
+ """
181
+ try:
182
+ decimal_value = Decimal(value.strip())
183
+ except (InvalidOperation, AttributeError) as exc:
184
+ raise typer.Exit(code=1) from exc
185
+ if decimal_value <= 0:
186
+ raise typer.Exit(code=1)
187
+ quantized = decimal_value.quantize(Decimal("0.000001"), rounding=ROUND_DOWN)
188
+ return int(quantized * Decimal(10**6))
189
+
190
+
191
+ def get_current_epoch_start(reference: datetime | None = None) -> datetime:
192
+ """Calculate the start of the current epoch (Friday 00:00 UTC).
193
+
194
+ Args:
195
+ reference: Reference datetime (defaults to now in UTC)
196
+
197
+ Returns:
198
+ Datetime of the current epoch start (Friday 00:00 UTC)
199
+ """
200
+ reference = reference or datetime.now(tz=UTC)
201
+ weekday = reference.weekday() # Monday=0, Friday=4
202
+ days_since_friday = (weekday - 4) % 7
203
+ candidate = datetime(
204
+ year=reference.year,
205
+ month=reference.month,
206
+ day=reference.day,
207
+ hour=0,
208
+ minute=0,
209
+ second=0,
210
+ microsecond=0,
211
+ tzinfo=UTC,
212
+ )
213
+ return candidate - timedelta(days=days_since_friday)
214
+
215
+
216
+ def get_next_epoch_freeze_time(reference: datetime | None = None) -> datetime:
217
+ """Calculate the next epoch freeze time (next Friday 00:00 UTC).
218
+
219
+ Args:
220
+ reference: Reference datetime (defaults to now in UTC)
221
+
222
+ Returns:
223
+ Datetime of the next epoch freeze (next Friday 00:00 UTC)
224
+ """
225
+ current_start = get_current_epoch_start(reference)
226
+ # If we're exactly at epoch start, next is in 7 days
227
+ # Otherwise, next is current + 7 days
228
+ return current_start + timedelta(days=7)
229
+
230
+
231
+ def format_countdown(seconds: float) -> str:
232
+ """Format seconds into a human-readable countdown string.
233
+
234
+ Args:
235
+ seconds: Number of seconds remaining
236
+
237
+ Returns:
238
+ Formatted string like "2d 5h 30m 15s"
239
+ """
240
+ if seconds < 0:
241
+ return "0s"
242
+
243
+ days = int(seconds // 86400)
244
+ hours = int((seconds % 86400) // 3600)
245
+ minutes = int((seconds % 3600) // 60)
246
+ secs = int(seconds % 60)
247
+
248
+ parts = []
249
+ if days > 0:
250
+ parts.append(f"{days}d")
251
+ if hours > 0:
252
+ parts.append(f"{hours}h")
253
+ if minutes > 0:
254
+ parts.append(f"{minutes}m")
255
+ if secs > 0 or not parts:
256
+ parts.append(f"{secs}s")
257
+
258
+ return " ".join(parts)
259
+
260
+
261
+ def get_local_timezone() -> ZoneInfo:
262
+ """Get the local timezone, with fallbacks.
263
+
264
+ Returns:
265
+ ZoneInfo object for local timezone
266
+ """
267
+ try:
268
+ return ZoneInfo("local")
269
+ except Exception:
270
+ fallback_tz = datetime.now().astimezone().tzinfo
271
+ if fallback_tz is None:
272
+ return ZoneInfo("UTC")
273
+ return fallback_tz # type: ignore[return-value]
274
+