nui-python-shared-utils 1.3.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.
- nui_lambda_shared_utils/__init__.py +252 -0
- nui_lambda_shared_utils/base_client.py +323 -0
- nui_lambda_shared_utils/cli.py +225 -0
- nui_lambda_shared_utils/cloudwatch_metrics.py +367 -0
- nui_lambda_shared_utils/config.py +136 -0
- nui_lambda_shared_utils/db_client.py +623 -0
- nui_lambda_shared_utils/error_handler.py +372 -0
- nui_lambda_shared_utils/es_client.py +460 -0
- nui_lambda_shared_utils/es_query_builder.py +315 -0
- nui_lambda_shared_utils/jwt_auth.py +277 -0
- nui_lambda_shared_utils/lambda_helpers.py +84 -0
- nui_lambda_shared_utils/log_processors.py +172 -0
- nui_lambda_shared_utils/powertools_helpers.py +263 -0
- nui_lambda_shared_utils/secrets_helper.py +187 -0
- nui_lambda_shared_utils/slack_client.py +675 -0
- nui_lambda_shared_utils/slack_formatter.py +307 -0
- nui_lambda_shared_utils/slack_setup/__init__.py +14 -0
- nui_lambda_shared_utils/slack_setup/channel_creator.py +295 -0
- nui_lambda_shared_utils/slack_setup/channel_definitions.py +187 -0
- nui_lambda_shared_utils/slack_setup/setup_helpers.py +211 -0
- nui_lambda_shared_utils/timezone.py +117 -0
- nui_lambda_shared_utils/utils.py +291 -0
- nui_python_shared_utils-1.3.0.dist-info/METADATA +470 -0
- nui_python_shared_utils-1.3.0.dist-info/RECORD +28 -0
- nui_python_shared_utils-1.3.0.dist-info/WHEEL +5 -0
- nui_python_shared_utils-1.3.0.dist-info/entry_points.txt +2 -0
- nui_python_shared_utils-1.3.0.dist-info/licenses/LICENSE +21 -0
- nui_python_shared_utils-1.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Channel definition models and configuration parser.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import yaml
|
|
7
|
+
from typing import List, Dict, Optional, Any
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ChannelDefinition:
|
|
13
|
+
"""Represents a Slack channel configuration."""
|
|
14
|
+
|
|
15
|
+
name: str
|
|
16
|
+
purpose: str
|
|
17
|
+
description: str
|
|
18
|
+
topic: str
|
|
19
|
+
invite_users: List[str] = field(default_factory=list)
|
|
20
|
+
service: Optional[str] = None
|
|
21
|
+
|
|
22
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
23
|
+
"""Convert to dictionary representation."""
|
|
24
|
+
return {
|
|
25
|
+
"name": self.name,
|
|
26
|
+
"purpose": self.purpose,
|
|
27
|
+
"description": self.description,
|
|
28
|
+
"topic": self.topic,
|
|
29
|
+
"invite_users": self.invite_users,
|
|
30
|
+
"service": self.service,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_dict(cls, data: Dict[str, Any], service: Optional[str] = None) -> "ChannelDefinition":
|
|
35
|
+
"""Create from dictionary."""
|
|
36
|
+
return cls(
|
|
37
|
+
name=data["name"],
|
|
38
|
+
purpose=data["purpose"],
|
|
39
|
+
description=data["description"],
|
|
40
|
+
topic=data["topic"],
|
|
41
|
+
invite_users=data.get("invite_users", []),
|
|
42
|
+
service=service or data.get("service"),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_channel_config(config_path: str) -> List[ChannelDefinition]:
|
|
47
|
+
"""
|
|
48
|
+
Load channel definitions from YAML config file.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
config_path: Path to YAML config file
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
List of channel definitions
|
|
55
|
+
|
|
56
|
+
Example YAML:
|
|
57
|
+
service: business-insights
|
|
58
|
+
default_invite_users:
|
|
59
|
+
- tim
|
|
60
|
+
channels:
|
|
61
|
+
- name: insights-daily
|
|
62
|
+
purpose: Daily operational metrics
|
|
63
|
+
description: Daily business insights for platform operations
|
|
64
|
+
topic: š Daily business metrics
|
|
65
|
+
- name: insights-executive
|
|
66
|
+
purpose: Executive summaries
|
|
67
|
+
description: Weekly executive reports
|
|
68
|
+
topic: šÆ Weekly executive summaries
|
|
69
|
+
invite_users:
|
|
70
|
+
- tim
|
|
71
|
+
- john
|
|
72
|
+
"""
|
|
73
|
+
if not os.path.exists(config_path):
|
|
74
|
+
raise FileNotFoundError(f"Config file not found: {config_path}")
|
|
75
|
+
|
|
76
|
+
with open(config_path, "r") as f:
|
|
77
|
+
config = yaml.safe_load(f)
|
|
78
|
+
|
|
79
|
+
if not config or "channels" not in config:
|
|
80
|
+
raise ValueError("Invalid config file: missing 'channels' section")
|
|
81
|
+
|
|
82
|
+
service = config.get("service")
|
|
83
|
+
default_invite_users = config.get("default_invite_users", [])
|
|
84
|
+
|
|
85
|
+
definitions = []
|
|
86
|
+
for channel_data in config["channels"]:
|
|
87
|
+
# Apply defaults
|
|
88
|
+
if "invite_users" not in channel_data and default_invite_users:
|
|
89
|
+
channel_data["invite_users"] = default_invite_users
|
|
90
|
+
|
|
91
|
+
definition = ChannelDefinition.from_dict(channel_data, service)
|
|
92
|
+
definitions.append(definition)
|
|
93
|
+
|
|
94
|
+
return definitions
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def generate_serverless_config(channel_map: Dict[str, str], service_name: str, output_format: str = "yaml") -> str:
|
|
98
|
+
"""
|
|
99
|
+
Generate serverless.yml configuration snippet.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
channel_map: Dict mapping channel names to IDs
|
|
103
|
+
service_name: Name of the service
|
|
104
|
+
output_format: 'yaml' or 'env'
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Configuration snippet
|
|
108
|
+
"""
|
|
109
|
+
if output_format == "yaml":
|
|
110
|
+
lines = ["# Add this to your serverless.yml custom section:", "custom:", " slack:", " channels:"]
|
|
111
|
+
|
|
112
|
+
# Map channel names to config keys
|
|
113
|
+
key_mapping = {
|
|
114
|
+
"alerts": "alerts",
|
|
115
|
+
"health": "health",
|
|
116
|
+
"debug": "debug",
|
|
117
|
+
"daily": "daily",
|
|
118
|
+
"weekly": "weekly",
|
|
119
|
+
"executive": "executive",
|
|
120
|
+
"insights": "insights",
|
|
121
|
+
"operations": "operations",
|
|
122
|
+
"activity": "activity",
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for channel_name, channel_id in channel_map.items():
|
|
126
|
+
# Extract key from channel name
|
|
127
|
+
key = None
|
|
128
|
+
for keyword, config_key in key_mapping.items():
|
|
129
|
+
if keyword in channel_name:
|
|
130
|
+
key = config_key
|
|
131
|
+
break
|
|
132
|
+
|
|
133
|
+
if not key:
|
|
134
|
+
# Default to last part of channel name
|
|
135
|
+
key = channel_name.split("-")[-1]
|
|
136
|
+
|
|
137
|
+
lines.append(f' {key}: "{channel_id}" # {channel_name}')
|
|
138
|
+
|
|
139
|
+
return "\n".join(lines)
|
|
140
|
+
|
|
141
|
+
elif output_format == "env":
|
|
142
|
+
lines = [
|
|
143
|
+
"# Environment variables for your application:",
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
for channel_name, channel_id in channel_map.items():
|
|
147
|
+
env_key = channel_name.upper().replace("-", "_") + "_CHANNEL"
|
|
148
|
+
lines.append(f"{env_key}={channel_id}")
|
|
149
|
+
|
|
150
|
+
return "\n".join(lines)
|
|
151
|
+
|
|
152
|
+
else:
|
|
153
|
+
raise ValueError(f"Unknown output format: {output_format}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def validate_channel_names(definitions: List[ChannelDefinition]) -> List[str]:
|
|
157
|
+
"""
|
|
158
|
+
Validate channel names according to Slack rules.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
definitions: List of channel definitions
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
List of validation errors (empty if all valid)
|
|
165
|
+
"""
|
|
166
|
+
errors = []
|
|
167
|
+
|
|
168
|
+
for definition in definitions:
|
|
169
|
+
name = definition.name
|
|
170
|
+
|
|
171
|
+
# Check length
|
|
172
|
+
if len(name) > 80:
|
|
173
|
+
errors.append(f"{name}: Channel name too long (max 80 chars)")
|
|
174
|
+
|
|
175
|
+
# Check for valid characters
|
|
176
|
+
if not all(c.isalnum() or c in "-_" for c in name):
|
|
177
|
+
errors.append(f"{name}: Invalid characters (use only letters, numbers, hyphens, underscores)")
|
|
178
|
+
|
|
179
|
+
# Check lowercase
|
|
180
|
+
if name != name.lower():
|
|
181
|
+
errors.append(f"{name}: Channel names must be lowercase")
|
|
182
|
+
|
|
183
|
+
# Check doesn't start with special chars
|
|
184
|
+
if name and name[0] in "-_":
|
|
185
|
+
errors.append(f"{name}: Channel name cannot start with hyphen or underscore")
|
|
186
|
+
|
|
187
|
+
return errors
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Helper functions for Slack setup operations.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import logging
|
|
8
|
+
from typing import List, Dict, Optional
|
|
9
|
+
from slack_sdk import WebClient
|
|
10
|
+
from slack_sdk.errors import SlackApiError
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SlackSetupHelper:
|
|
16
|
+
"""Helper utilities for Slack setup operations."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, token: Optional[str] = None):
|
|
19
|
+
"""Initialize with Slack token."""
|
|
20
|
+
self.token = token or os.environ.get("SLACK_BOT_TOKEN")
|
|
21
|
+
if not self.token:
|
|
22
|
+
raise ValueError("No Slack bot token provided")
|
|
23
|
+
|
|
24
|
+
self.client = WebClient(token=self.token)
|
|
25
|
+
|
|
26
|
+
def test_channel_access(self, channel_ids: List[str]) -> Dict[str, bool]:
|
|
27
|
+
"""
|
|
28
|
+
Test if bot can post to channels.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
channel_ids: List of channel IDs to test
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Dict mapping channel IDs to success status
|
|
35
|
+
"""
|
|
36
|
+
results = {}
|
|
37
|
+
|
|
38
|
+
for channel_id in channel_ids:
|
|
39
|
+
try:
|
|
40
|
+
self.client.chat_postMessage(channel=channel_id, text="š§ Testing channel access...")
|
|
41
|
+
results[channel_id] = True
|
|
42
|
+
log.info(f"ā
Can post to channel {channel_id}")
|
|
43
|
+
except SlackApiError as e:
|
|
44
|
+
results[channel_id] = False
|
|
45
|
+
log.error(f"ā Cannot post to channel {channel_id}: {e.response['error']}")
|
|
46
|
+
|
|
47
|
+
return results
|
|
48
|
+
|
|
49
|
+
def get_channel_info(self, channel_id: str) -> Optional[Dict]:
|
|
50
|
+
"""Get detailed channel information."""
|
|
51
|
+
try:
|
|
52
|
+
response = self.client.conversations_info(channel=channel_id)
|
|
53
|
+
return response["channel"]
|
|
54
|
+
except SlackApiError as e:
|
|
55
|
+
log.error(f"Error getting channel info: {e}")
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
def list_bot_channels(self) -> List[Dict]:
|
|
59
|
+
"""List all channels the bot is a member of."""
|
|
60
|
+
channels = []
|
|
61
|
+
cursor = None
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
while True:
|
|
65
|
+
response = self.client.conversations_list(exclude_archived=True, types="public_channel", cursor=cursor)
|
|
66
|
+
|
|
67
|
+
# Filter to only channels bot is member of
|
|
68
|
+
for channel in response["channels"]:
|
|
69
|
+
if channel.get("is_member", False):
|
|
70
|
+
channels.append(
|
|
71
|
+
{
|
|
72
|
+
"id": channel["id"],
|
|
73
|
+
"name": channel["name"],
|
|
74
|
+
"purpose": channel.get("purpose", {}).get("value", ""),
|
|
75
|
+
"topic": channel.get("topic", {}).get("value", ""),
|
|
76
|
+
"num_members": channel.get("num_members", 0),
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
cursor = response.get("response_metadata", {}).get("next_cursor")
|
|
81
|
+
if not cursor:
|
|
82
|
+
break
|
|
83
|
+
|
|
84
|
+
except SlackApiError as e:
|
|
85
|
+
log.error(f"Error listing channels: {e}")
|
|
86
|
+
|
|
87
|
+
return channels
|
|
88
|
+
|
|
89
|
+
def validate_bot_permissions(self) -> Dict[str, bool]:
|
|
90
|
+
"""Check if bot has required permissions."""
|
|
91
|
+
required_scopes = ["channels:manage", "channels:join", "chat:write", "channels:read", "users:read"]
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
response = self.client.auth_test()
|
|
95
|
+
# Note: auth.test doesn't return scopes in newer apps
|
|
96
|
+
# This would need to be checked via the app's OAuth settings
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
"authenticated": True,
|
|
100
|
+
"bot_id": response.get("user_id"),
|
|
101
|
+
"bot_name": response.get("user"),
|
|
102
|
+
"team": response.get("team"),
|
|
103
|
+
"url": response.get("url"),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
except SlackApiError as e:
|
|
107
|
+
log.error(f"Auth test failed: {e}")
|
|
108
|
+
return {"authenticated": False, "error": str(e)}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def prompt_for_token() -> str:
|
|
112
|
+
"""Interactive prompt for Slack bot token."""
|
|
113
|
+
print("\n" + "=" * 60)
|
|
114
|
+
print("Slack Bot Token Required")
|
|
115
|
+
print("=" * 60)
|
|
116
|
+
print("\nTo get your bot token:")
|
|
117
|
+
print("1. Go to https://api.slack.com/apps")
|
|
118
|
+
print("2. Select your app (or create a new one)")
|
|
119
|
+
print("3. Go to 'OAuth & Permissions'")
|
|
120
|
+
print("4. Copy the 'Bot User OAuth Token' (starts with xoxb-)")
|
|
121
|
+
print("\nRequired OAuth Scopes:")
|
|
122
|
+
print(" - channels:manage (create channels)")
|
|
123
|
+
print(" - channels:join (join channels)")
|
|
124
|
+
print(" - chat:write (send messages)")
|
|
125
|
+
print(" - channels:read (list channels)")
|
|
126
|
+
print(" - users:read (look up users)")
|
|
127
|
+
print(" - channels:write.invites (invite users)")
|
|
128
|
+
print("\n" + "=" * 60)
|
|
129
|
+
|
|
130
|
+
token = input("\nEnter Bot Token (xoxb-...): ").strip()
|
|
131
|
+
|
|
132
|
+
if not token.startswith("xoxb-"):
|
|
133
|
+
print("\nā ļø Warning: Token doesn't start with 'xoxb-'")
|
|
134
|
+
confirm = input("Continue anyway? (y/n): ")
|
|
135
|
+
if confirm.lower() != "y":
|
|
136
|
+
sys.exit(1)
|
|
137
|
+
|
|
138
|
+
return token
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def print_channel_summary(channel_map: Dict[str, str], service_name: str):
|
|
142
|
+
"""Print summary of created channels."""
|
|
143
|
+
print("\n" + "=" * 60)
|
|
144
|
+
print(f"Channel Setup Complete for {service_name}")
|
|
145
|
+
print("=" * 60)
|
|
146
|
+
|
|
147
|
+
if not channel_map:
|
|
148
|
+
print("\nā No channels were created")
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
print("\nā
Channels ready:")
|
|
152
|
+
for name, channel_id in channel_map.items():
|
|
153
|
+
print(f" #{name:<30} ID: {channel_id}")
|
|
154
|
+
|
|
155
|
+
print("\nš Next steps:")
|
|
156
|
+
print("1. Use the channel IDs in your application configuration")
|
|
157
|
+
print("2. Deploy your application")
|
|
158
|
+
print("3. Test by sending messages to the channels")
|
|
159
|
+
|
|
160
|
+
print("\nš” Tips:")
|
|
161
|
+
print("- Bot is already in all channels")
|
|
162
|
+
print("- Invited users have been added to channels")
|
|
163
|
+
print("- Channels are public for transparency")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def confirm_channel_creation(definitions: List["ChannelDefinition"], existing: Dict[str, Optional[str]]) -> bool:
|
|
167
|
+
"""
|
|
168
|
+
Interactive confirmation for channel creation.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
definitions: Channel definitions to create
|
|
172
|
+
existing: Map of existing channels
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
True if user confirms, False otherwise
|
|
176
|
+
"""
|
|
177
|
+
print("\n" + "=" * 60)
|
|
178
|
+
print("Channel Creation Plan")
|
|
179
|
+
print("=" * 60)
|
|
180
|
+
|
|
181
|
+
to_create = []
|
|
182
|
+
already_exist = []
|
|
183
|
+
|
|
184
|
+
for definition in definitions:
|
|
185
|
+
if existing.get(definition.name):
|
|
186
|
+
already_exist.append(definition)
|
|
187
|
+
else:
|
|
188
|
+
to_create.append(definition)
|
|
189
|
+
|
|
190
|
+
if already_exist:
|
|
191
|
+
print("\nā
Existing channels (will configure/join):")
|
|
192
|
+
for definition in already_exist:
|
|
193
|
+
print(f" #{definition.name}")
|
|
194
|
+
print(f" Purpose: {definition.purpose}")
|
|
195
|
+
|
|
196
|
+
if to_create:
|
|
197
|
+
print("\nš Channels to create:")
|
|
198
|
+
for definition in to_create:
|
|
199
|
+
print(f" #{definition.name}")
|
|
200
|
+
print(f" Purpose: {definition.purpose}")
|
|
201
|
+
print(f" Topic: {definition.topic}")
|
|
202
|
+
if definition.invite_users:
|
|
203
|
+
print(f" Inviting: {', '.join(definition.invite_users)}")
|
|
204
|
+
|
|
205
|
+
if not to_create and not already_exist:
|
|
206
|
+
print("\nā No channels to process")
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
print("\n" + "=" * 60)
|
|
210
|
+
response = input("\nProceed with channel setup? (y/n): ")
|
|
211
|
+
return response.lower() == "y"
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Timezone utilities for multi-timezone handling (NZ primary, AUS/US/EUR secondary).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
import pytz
|
|
7
|
+
from typing import List, Tuple, Optional
|
|
8
|
+
|
|
9
|
+
# Supported timezones
|
|
10
|
+
NZ_TZ = pytz.timezone("Pacific/Auckland") # Primary timezone
|
|
11
|
+
AUS_TZ = pytz.timezone("Australia/Sydney")
|
|
12
|
+
US_EAST_TZ = pytz.timezone("US/Eastern")
|
|
13
|
+
US_WEST_TZ = pytz.timezone("US/Pacific")
|
|
14
|
+
EUR_TZ = pytz.timezone("Europe/London")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def nz_time(utc_dt: Optional[datetime] = None) -> datetime:
|
|
18
|
+
"""
|
|
19
|
+
Convert UTC datetime to NZ time or get current NZ time.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
utc_dt: UTC datetime to convert (defaults to now)
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Datetime in NZ timezone
|
|
26
|
+
"""
|
|
27
|
+
if utc_dt is None:
|
|
28
|
+
utc_dt = datetime.now(timezone.utc)
|
|
29
|
+
|
|
30
|
+
if utc_dt.tzinfo is None:
|
|
31
|
+
utc_dt = pytz.UTC.localize(utc_dt)
|
|
32
|
+
|
|
33
|
+
return utc_dt.astimezone(NZ_TZ)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def format_nz_time(dt: Optional[datetime] = None, fmt: str = "%Y-%m-%d %H:%M:%S %Z") -> str:
|
|
37
|
+
"""
|
|
38
|
+
Format datetime as NZ time string.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
dt: Datetime to format (defaults to now)
|
|
42
|
+
fmt: strftime format string
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Formatted time string
|
|
46
|
+
"""
|
|
47
|
+
nz_dt = nz_time(dt)
|
|
48
|
+
return nz_dt.strftime(fmt)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def format_multi_timezone(dt: Optional[datetime] = None, primary_tz: str = "nz", secondary_tz: str = "aus") -> str:
|
|
52
|
+
"""
|
|
53
|
+
Format datetime with primary and secondary timezone display.
|
|
54
|
+
|
|
55
|
+
Example output: "Fri 26th 10am NZDT (Thu 8pm AEDT)"
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
dt: Datetime to format (defaults to now)
|
|
59
|
+
primary_tz: Primary timezone ("nz", "aus", "us_east", "us_west", "eur")
|
|
60
|
+
secondary_tz: Secondary timezone for parenthetical display
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Formatted multi-timezone string
|
|
64
|
+
"""
|
|
65
|
+
if dt is None:
|
|
66
|
+
dt = datetime.now(timezone.utc)
|
|
67
|
+
|
|
68
|
+
if dt.tzinfo is None:
|
|
69
|
+
dt = pytz.UTC.localize(dt)
|
|
70
|
+
|
|
71
|
+
# Timezone mapping
|
|
72
|
+
tz_map = {"nz": NZ_TZ, "aus": AUS_TZ, "us_east": US_EAST_TZ, "us_west": US_WEST_TZ, "eur": EUR_TZ}
|
|
73
|
+
|
|
74
|
+
primary_tzinfo = tz_map.get(primary_tz, NZ_TZ)
|
|
75
|
+
secondary_tzinfo = tz_map.get(secondary_tz, AUS_TZ)
|
|
76
|
+
|
|
77
|
+
# Convert to both timezones
|
|
78
|
+
primary_dt = dt.astimezone(primary_tzinfo)
|
|
79
|
+
secondary_dt = dt.astimezone(secondary_tzinfo)
|
|
80
|
+
|
|
81
|
+
# Format primary timezone with ordinal
|
|
82
|
+
def format_ordinal(day):
|
|
83
|
+
if 10 <= day % 100 <= 20:
|
|
84
|
+
suffix = "th"
|
|
85
|
+
else:
|
|
86
|
+
suffix = {1: "st", 2: "nd", 3: "rd"}.get(day % 10, "th")
|
|
87
|
+
return f"{day}{suffix}"
|
|
88
|
+
|
|
89
|
+
# Primary format: "Fri 26th 10am NZDT"
|
|
90
|
+
primary_hour = primary_dt.strftime("%I").lstrip("0") or "12"
|
|
91
|
+
primary_tz_abbr = primary_dt.strftime("%Z")
|
|
92
|
+
primary_formatted = f"{primary_dt.strftime('%a')} {format_ordinal(primary_dt.day)} {primary_hour}{primary_dt.strftime('%p').lower()} {primary_tz_abbr}"
|
|
93
|
+
|
|
94
|
+
# Secondary format: "Thu 8pm AEDT"
|
|
95
|
+
secondary_hour = secondary_dt.strftime("%I").lstrip("0") or "12"
|
|
96
|
+
secondary_tz_abbr = secondary_dt.strftime("%Z")
|
|
97
|
+
secondary_formatted = (
|
|
98
|
+
f"{secondary_dt.strftime('%a')} {secondary_hour}{secondary_dt.strftime('%p').lower()} {secondary_tz_abbr}"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return f"{primary_formatted} ({secondary_formatted})"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def get_timezone_options() -> List[Tuple[str, str]]:
|
|
105
|
+
"""
|
|
106
|
+
Get available timezone options for configuration.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
List of (key, description) tuples
|
|
110
|
+
"""
|
|
111
|
+
return [
|
|
112
|
+
("nz", "New Zealand (Pacific/Auckland)"),
|
|
113
|
+
("aus", "Australia (Australia/Sydney)"),
|
|
114
|
+
("us_east", "US Eastern (US/Eastern)"),
|
|
115
|
+
("us_west", "US Pacific (US/Pacific)"),
|
|
116
|
+
("eur", "Europe (Europe/London)"),
|
|
117
|
+
]
|