isobar-cli 1.1.1__tar.gz → 1.2.0__tar.gz

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.
Files changed (25) hide show
  1. {isobar_cli-1.1.1/src/isobar_cli.egg-info → isobar_cli-1.2.0}/PKG-INFO +54 -7
  2. {isobar_cli-1.1.1 → isobar_cli-1.2.0}/README.md +51 -6
  3. {isobar_cli-1.1.1 → isobar_cli-1.2.0}/pyproject.toml +12 -9
  4. isobar_cli-1.2.0/src/isobar_cli/__init__.py +1 -0
  5. {isobar_cli-1.1.1 → isobar_cli-1.2.0}/src/isobar_cli/api.py +42 -10
  6. {isobar_cli-1.1.1 → isobar_cli-1.2.0}/src/isobar_cli/config.py +21 -0
  7. {isobar_cli-1.1.1 → isobar_cli-1.2.0}/src/isobar_cli/logic.py +72 -2
  8. {isobar_cli-1.1.1 → isobar_cli-1.2.0}/src/isobar_cli/main.py +62 -44
  9. {isobar_cli-1.1.1 → isobar_cli-1.2.0/src/isobar_cli.egg-info}/PKG-INFO +54 -7
  10. {isobar_cli-1.1.1 → isobar_cli-1.2.0}/src/isobar_cli.egg-info/requires.txt +3 -0
  11. {isobar_cli-1.1.1 → isobar_cli-1.2.0}/tests/test_isobar_extra.py +60 -51
  12. {isobar_cli-1.1.1 → isobar_cli-1.2.0}/tests/test_main.py +9 -8
  13. isobar_cli-1.1.1/src/isobar_cli/__init__.py +0 -1
  14. {isobar_cli-1.1.1 → isobar_cli-1.2.0}/LICENSE +0 -0
  15. {isobar_cli-1.1.1 → isobar_cli-1.2.0}/setup.cfg +0 -0
  16. {isobar_cli-1.1.1 → isobar_cli-1.2.0}/src/isobar_cli/location.py +0 -0
  17. {isobar_cli-1.1.1 → isobar_cli-1.2.0}/src/isobar_cli/models.py +0 -0
  18. {isobar_cli-1.1.1 → isobar_cli-1.2.0}/src/isobar_cli/ui.py +0 -0
  19. {isobar_cli-1.1.1 → isobar_cli-1.2.0}/src/isobar_cli.egg-info/SOURCES.txt +0 -0
  20. {isobar_cli-1.1.1 → isobar_cli-1.2.0}/src/isobar_cli.egg-info/dependency_links.txt +0 -0
  21. {isobar_cli-1.1.1 → isobar_cli-1.2.0}/src/isobar_cli.egg-info/entry_points.txt +0 -0
  22. {isobar_cli-1.1.1 → isobar_cli-1.2.0}/src/isobar_cli.egg-info/top_level.txt +0 -0
  23. {isobar_cli-1.1.1 → isobar_cli-1.2.0}/tests/test_api.py +0 -0
  24. {isobar_cli-1.1.1 → isobar_cli-1.2.0}/tests/test_location.py +0 -0
  25. {isobar_cli-1.1.1 → isobar_cli-1.2.0}/tests/test_ui.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: isobar-cli
3
- Version: 1.1.1
3
+ Version: 1.2.0
4
4
  Summary: A visually pleasing terminal weather tool focusing on Real Feel and Windchill.
5
5
  Author: Beau Bremer / KnowOneActual
6
6
  License-Expression: MIT
@@ -29,6 +29,8 @@ Provides-Extra: test
29
29
  Requires-Dist: pytest>=7.0.0; extra == "test"
30
30
  Requires-Dist: requests-mock>=1.11.0; extra == "test"
31
31
  Requires-Dist: pytest-cov>=4.1.0; extra == "test"
32
+ Provides-Extra: timezone
33
+ Requires-Dist: pytz>=2024.1; extra == "timezone"
32
34
  Dynamic: license-file
33
35
 
34
36
  # Isobar CLI
@@ -36,7 +38,7 @@ Dynamic: license-file
36
38
  ![CI](https://github.com/KnowOneActual/isobar-cli/actions/workflows/ci.yml/badge.svg)
37
39
  ![Coverage](https://img.shields.io/badge/coverage-98%25-green)
38
40
  [![PyPI version](https://badge.fury.io/py/isobar-cli.svg)](https://badge.fury.io/py/isobar-cli)
39
- ![Version](https://img.shields.io/badge/version-1.0.1-blue)
41
+ ![Version](https://img.shields.io/badge/version-1.2.0-blue)
40
42
  ![Ruff](https://img.shields.io/badge/linting-ruff-purple)
41
43
  ![Python](https://img.shields.io/badge/python-3.8%2B-blue)
42
44
  ![License](https://img.shields.io/badge/license-MIT-green)
@@ -80,7 +82,7 @@ Most weather apps overwhelm with data. Isobar strips away everything except what
80
82
  - **Temporal Context** — Comparison with previous day conditions 📈
81
83
  - **UV Index Monitoring** — Sun protection guidance with intensity levels ☀️
82
84
  - **Wind Gust Alerts** — Highlighting of significant gust events 💨⚠️
83
- - **Home City Persistence** — Set a default city with `isobar home "Your City"` 🏠
85
+ - **Home City Persistence** — Set a default city with `isobar home "Your City"` 🏠 *(Note: Due to a Typer limitation, this currently shows weather for "Home, Kansas" instead of setting home city. Manual config editing required.)*
84
86
 
85
87
  ## 🚀 Installation
86
88
 
@@ -124,12 +126,16 @@ isobar London Tokyo Paris # Multiple cities
124
126
  isobar "New York" # Use quotes for multi-word cities
125
127
 
126
128
  # Hourly outlook (next 12h)
127
- isobar --hourly
128
- isobar -H
129
+ isobar --hourly Chicago
130
+ isobar -H Chicago
129
131
 
130
132
  # 7-day forecast
131
- isobar --forecast
132
- isobar -f
133
+ isobar --forecast Chicago
134
+ isobar -f Chicago
135
+
136
+ # Note: Flags must come before city names
137
+ # ✅ isobar -H Chicago
138
+ # ❌ isobar Chicago -H (treats "-H" as a city name)
133
139
  isobar "San Francisco" --forecast
134
140
  isobar -f Sydney
135
141
 
@@ -145,6 +151,42 @@ isobar home --clear # Clear home city
145
151
  isobar # Uses home city if set (otherwise auto-detects)
146
152
  ```
147
153
 
154
+ ## ⚙️ Configuration
155
+
156
+ Isobar supports configuration via environment variables for advanced use cases:
157
+
158
+ ### API Endpoint Configuration
159
+ Customize API endpoints for different weather providers or testing:
160
+
161
+ ```bash
162
+ # Use custom weather APIs
163
+ export ISOBAR_GEOCODING_URL="https://custom-geocoding-api.example.com/v1/search"
164
+ export ISOBAR_WEATHER_URL="https://custom-weather-api.example.com/v1/forecast"
165
+ export ISOBAR_AQI_URL="https://custom-aqi-api.example.com/v1/air-quality"
166
+
167
+ # Run with custom endpoints
168
+ isobar "New York"
169
+ ```
170
+
171
+ ### Timezone Support
172
+ For enhanced timezone accuracy (optional):
173
+
174
+ ```bash
175
+ # Install optional timezone support
176
+ pip install isobar-cli[timezone]
177
+
178
+ # Sunrise/sunset will now display in local timezone
179
+ isobar London
180
+ ```
181
+
182
+ ### Debug Mode
183
+ Enable debug logging to stderr:
184
+
185
+ ```bash
186
+ # View API errors and debugging information
187
+ isobar "Test City" 2> debug.log
188
+ ```
189
+
148
190
  ## ⌨️ Shell Completion
149
191
 
150
192
  Isobar supports tab-completion for city names. To enable it for a shell:
@@ -212,6 +254,10 @@ Preparation Guidance:
212
254
  | `config.py` | Persistent home city configuration |
213
255
  | Enhanced `logic.py` | Preparation guidance, UV monitoring, gust alerts |
214
256
  | Updated `ui.py` | Contextual display of insights |
257
+ | **v1.2.0 Features** | **Security & Configuration** |
258
+ | Configurable API Endpoints | Environment variable support for custom APIs |
259
+ | Enhanced Error Handling | Specific exception catching with timeouts |
260
+ | Timezone Support | Optional `pytz` dependency for local time display |
215
261
 
216
262
  ## 🔒 Security
217
263
 
@@ -235,6 +281,7 @@ All security scans are integrated into the CI/CD pipeline and run on every push,
235
281
  ✅ **Phase 5 Complete** — Testing & Reliability
236
282
  ✅ **Phase 6 Complete** — Distribution (PyPI, Homebrew)
237
283
  ✅ **Phase 7 Complete** — Intuition & Analysis (v1.1.0)
284
+ ✅ **v1.2.0 Complete** — Security & Configuration Enhancements
238
285
  Refer to [ROADMAP.md](ROADMAP.md) and [CHANGELOG.md](CHANGELOG.md) for details.
239
286
 
240
287
  ## 🤝 Contributing
@@ -3,7 +3,7 @@
3
3
  ![CI](https://github.com/KnowOneActual/isobar-cli/actions/workflows/ci.yml/badge.svg)
4
4
  ![Coverage](https://img.shields.io/badge/coverage-98%25-green)
5
5
  [![PyPI version](https://badge.fury.io/py/isobar-cli.svg)](https://badge.fury.io/py/isobar-cli)
6
- ![Version](https://img.shields.io/badge/version-1.0.1-blue)
6
+ ![Version](https://img.shields.io/badge/version-1.2.0-blue)
7
7
  ![Ruff](https://img.shields.io/badge/linting-ruff-purple)
8
8
  ![Python](https://img.shields.io/badge/python-3.8%2B-blue)
9
9
  ![License](https://img.shields.io/badge/license-MIT-green)
@@ -47,7 +47,7 @@ Most weather apps overwhelm with data. Isobar strips away everything except what
47
47
  - **Temporal Context** — Comparison with previous day conditions 📈
48
48
  - **UV Index Monitoring** — Sun protection guidance with intensity levels ☀️
49
49
  - **Wind Gust Alerts** — Highlighting of significant gust events 💨⚠️
50
- - **Home City Persistence** — Set a default city with `isobar home "Your City"` 🏠
50
+ - **Home City Persistence** — Set a default city with `isobar home "Your City"` 🏠 *(Note: Due to a Typer limitation, this currently shows weather for "Home, Kansas" instead of setting home city. Manual config editing required.)*
51
51
 
52
52
  ## 🚀 Installation
53
53
 
@@ -91,12 +91,16 @@ isobar London Tokyo Paris # Multiple cities
91
91
  isobar "New York" # Use quotes for multi-word cities
92
92
 
93
93
  # Hourly outlook (next 12h)
94
- isobar --hourly
95
- isobar -H
94
+ isobar --hourly Chicago
95
+ isobar -H Chicago
96
96
 
97
97
  # 7-day forecast
98
- isobar --forecast
99
- isobar -f
98
+ isobar --forecast Chicago
99
+ isobar -f Chicago
100
+
101
+ # Note: Flags must come before city names
102
+ # ✅ isobar -H Chicago
103
+ # ❌ isobar Chicago -H (treats "-H" as a city name)
100
104
  isobar "San Francisco" --forecast
101
105
  isobar -f Sydney
102
106
 
@@ -112,6 +116,42 @@ isobar home --clear # Clear home city
112
116
  isobar # Uses home city if set (otherwise auto-detects)
113
117
  ```
114
118
 
119
+ ## ⚙️ Configuration
120
+
121
+ Isobar supports configuration via environment variables for advanced use cases:
122
+
123
+ ### API Endpoint Configuration
124
+ Customize API endpoints for different weather providers or testing:
125
+
126
+ ```bash
127
+ # Use custom weather APIs
128
+ export ISOBAR_GEOCODING_URL="https://custom-geocoding-api.example.com/v1/search"
129
+ export ISOBAR_WEATHER_URL="https://custom-weather-api.example.com/v1/forecast"
130
+ export ISOBAR_AQI_URL="https://custom-aqi-api.example.com/v1/air-quality"
131
+
132
+ # Run with custom endpoints
133
+ isobar "New York"
134
+ ```
135
+
136
+ ### Timezone Support
137
+ For enhanced timezone accuracy (optional):
138
+
139
+ ```bash
140
+ # Install optional timezone support
141
+ pip install isobar-cli[timezone]
142
+
143
+ # Sunrise/sunset will now display in local timezone
144
+ isobar London
145
+ ```
146
+
147
+ ### Debug Mode
148
+ Enable debug logging to stderr:
149
+
150
+ ```bash
151
+ # View API errors and debugging information
152
+ isobar "Test City" 2> debug.log
153
+ ```
154
+
115
155
  ## ⌨️ Shell Completion
116
156
 
117
157
  Isobar supports tab-completion for city names. To enable it for a shell:
@@ -179,6 +219,10 @@ Preparation Guidance:
179
219
  | `config.py` | Persistent home city configuration |
180
220
  | Enhanced `logic.py` | Preparation guidance, UV monitoring, gust alerts |
181
221
  | Updated `ui.py` | Contextual display of insights |
222
+ | **v1.2.0 Features** | **Security & Configuration** |
223
+ | Configurable API Endpoints | Environment variable support for custom APIs |
224
+ | Enhanced Error Handling | Specific exception catching with timeouts |
225
+ | Timezone Support | Optional `pytz` dependency for local time display |
182
226
 
183
227
  ## 🔒 Security
184
228
 
@@ -202,6 +246,7 @@ All security scans are integrated into the CI/CD pipeline and run on every push,
202
246
  ✅ **Phase 5 Complete** — Testing & Reliability
203
247
  ✅ **Phase 6 Complete** — Distribution (PyPI, Homebrew)
204
248
  ✅ **Phase 7 Complete** — Intuition & Analysis (v1.1.0)
249
+ ✅ **v1.2.0 Complete** — Security & Configuration Enhancements
205
250
  Refer to [ROADMAP.md](ROADMAP.md) and [CHANGELOG.md](CHANGELOG.md) for details.
206
251
 
207
252
  ## 🤝 Contributing
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "isobar-cli"
7
- version = "1.1.1"
7
+ version = "1.2.0"
8
8
  description = "A visually pleasing terminal weather tool focusing on Real Feel and Windchill."
9
9
  authors = [
10
10
  { name="Beau Bremer / KnowOneActual" },
@@ -27,9 +27,9 @@ dependencies = [
27
27
  "rich>=13.0.0",
28
28
  "requests>=2.31.0",
29
29
  "typer>=0.9.0",
30
- "timezonefinder>=6.0.0",
31
- ]
32
- requires-python = ">=3.8"
30
+ "timezonefinder>=6.0.0",
31
+ ]
32
+ requires-python = ">=3.8"
33
33
 
34
34
  [project.urls]
35
35
  Homepage = "https://github.com/KnowOneActual/isobar-cli"
@@ -39,11 +39,14 @@ dependencies = [
39
39
 
40
40
  [project.optional-dependencies]
41
41
 
42
- test = [
43
- "pytest>=7.0.0",
44
- "requests-mock>=1.11.0",
45
- "pytest-cov>=4.1.0",
46
- ]
42
+ test = [
43
+ "pytest>=7.0.0",
44
+ "requests-mock>=1.11.0",
45
+ "pytest-cov>=4.1.0",
46
+ ]
47
+ timezone = [
48
+ "pytz>=2024.1",
49
+ ]
47
50
 
48
51
  [project.scripts]
49
52
  isobar = "isobar_cli.main:app" # This allows you to type 'isobar' in the terminal to run it
@@ -0,0 +1 @@
1
+ __version__ = "1.1.1"
@@ -7,6 +7,7 @@ from typing import Optional
7
7
  import requests
8
8
  from timezonefinder import TimezoneFinder
9
9
 
10
+ from .config import get_aqi_url, get_geocoding_url, get_weather_url
10
11
  from .logic import format_time
11
12
  from .models import ForecastDay, HourlyForecast, UnitSystem, WeatherData, WeatherUnits
12
13
 
@@ -15,22 +16,39 @@ CACHE_DIR.mkdir(parents=True, exist_ok=True)
15
16
 
16
17
 
17
18
  class GeocodingClient:
18
- BASE_URL = "https://geocoding-api.open-meteo.com/v1/search"
19
+ @classmethod
20
+ def get_base_url(cls) -> str:
21
+ """Get geocoding API URL from configuration."""
22
+ return get_geocoding_url()
19
23
 
20
24
  @classmethod
21
25
  def search(cls, city: str, count: int = 1) -> list[dict]:
22
26
  try:
23
27
  response = requests.get(
24
- f"{cls.BASE_URL}?name={city}&count={count}&format=json"
28
+ f"{cls.get_base_url()}?name={city}&count={count}&format=json",
29
+ timeout=10,
25
30
  )
26
31
  response.raise_for_status()
27
32
  return response.json().get("results", [])
28
- except Exception:
33
+ except requests.exceptions.RequestException as e:
34
+ # Log error for debugging but don't crash
35
+ import sys
36
+
37
+ print(f"Geocoding error for '{city}': {e}", file=sys.stderr)
38
+ return []
39
+ except Exception as e:
40
+ # Catch-all for unexpected errors
41
+ import sys
42
+
43
+ print(f"Unexpected geocoding error for '{city}': {e}", file=sys.stderr)
29
44
  return []
30
45
 
31
46
 
32
47
  class WeatherClient:
33
- BASE_URL = "https://api.open-meteo.com/v1/forecast"
48
+ @classmethod
49
+ def get_base_url(cls) -> str:
50
+ """Get weather API URL from configuration."""
51
+ return get_weather_url()
34
52
 
35
53
  def __init__(self, lat: float, lon: float, timezone: str, metric: bool = False):
36
54
  self.lat = lat
@@ -59,23 +77,37 @@ class WeatherClient:
59
77
  "forecast_days": 7,
60
78
  }
61
79
 
62
- response = requests.get(self.BASE_URL, params=params)
80
+ response = requests.get(self.get_base_url(), params=params, timeout=15)
63
81
  response.raise_for_status()
64
82
  return response.json()
65
83
 
66
84
 
67
85
  class AirQualityClient:
68
- BASE_URL = "https://air-quality-api.open-meteo.com/v1/air-quality"
86
+ @classmethod
87
+ def get_base_url(cls) -> str:
88
+ """Get air quality API URL from configuration."""
89
+ return get_aqi_url()
69
90
 
70
91
  @classmethod
71
92
  def get_aqi(cls, lat: float, lon: float) -> Optional[int]:
72
93
  try:
73
94
  response = requests.get(
74
- f"{cls.BASE_URL}?latitude={lat}&longitude={lon}&current=us_aqi"
95
+ f"{cls.get_base_url()}?latitude={lat}&longitude={lon}&current=us_aqi",
96
+ timeout=10,
75
97
  )
76
98
  response.raise_for_status()
77
99
  return response.json().get("current", {}).get("us_aqi")
78
- except Exception:
100
+ except requests.exceptions.RequestException as e:
101
+ # Log error for debugging but don't crash
102
+ import sys
103
+
104
+ print(f"AQI error for ({lat},{lon}): {e}", file=sys.stderr)
105
+ return None
106
+ except Exception as e:
107
+ # Catch-all for unexpected errors
108
+ import sys
109
+
110
+ print(f"Unexpected AQI error for ({lat},{lon}): {e}", file=sys.stderr)
79
111
  return None
80
112
 
81
113
 
@@ -207,8 +239,8 @@ def get_weather_data(city: str, metric: bool = False) -> Optional[WeatherData]:
207
239
  precip_prob=round(avg_precip_prob),
208
240
  rainfall=next_6h_rain,
209
241
  snowfall=next_6h_snow,
210
- sunrise=format_time(daily["sunrise"][0]),
211
- sunset=format_time(daily["sunset"][0]),
242
+ sunrise=format_time(daily["sunrise"][0], timezone),
243
+ sunset=format_time(daily["sunset"][0], timezone),
212
244
  forecast=forecast,
213
245
  hourly=hourly_forecast,
214
246
  units=weather_client.units,
@@ -1,12 +1,18 @@
1
1
  """Configuration module for Isobar CLI persistent settings."""
2
2
 
3
3
  import json
4
+ import os
4
5
  from pathlib import Path
5
6
  from typing import Optional
6
7
 
7
8
  CONFIG_DIR = Path.home() / ".config" / "isobar"
8
9
  CONFIG_FILE = CONFIG_DIR / "config.json"
9
10
 
11
+ # Default API endpoints (can be overridden by environment variables)
12
+ DEFAULT_GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"
13
+ DEFAULT_WEATHER_URL = "https://api.open-meteo.com/v1/forecast"
14
+ DEFAULT_AQI_URL = "https://air-quality-api.open-meteo.com/v1/air-quality"
15
+
10
16
 
11
17
  def ensure_config_dir() -> None:
12
18
  """Create config directory if it doesn't exist."""
@@ -72,3 +78,18 @@ def clear_home_city() -> None:
72
78
  def get_config_path() -> Path:
73
79
  """Get the path to the config file (for debugging/info)."""
74
80
  return CONFIG_FILE
81
+
82
+
83
+ def get_geocoding_url() -> str:
84
+ """Get geocoding API URL from environment or default."""
85
+ return os.environ.get("ISOBAR_GEOCODING_URL", DEFAULT_GEOCODING_URL)
86
+
87
+
88
+ def get_weather_url() -> str:
89
+ """Get weather API URL from environment or default."""
90
+ return os.environ.get("ISOBAR_WEATHER_URL", DEFAULT_WEATHER_URL)
91
+
92
+
93
+ def get_aqi_url() -> str:
94
+ """Get air quality API URL from environment or default."""
95
+ return os.environ.get("ISOBAR_AQI_URL", DEFAULT_AQI_URL)
@@ -31,16 +31,86 @@ WMO_CODES: dict[int, tuple[str, str]] = {
31
31
  }
32
32
 
33
33
 
34
- def format_time(iso_string: str) -> str:
35
- """Convert ISO 8601 datetime to 12-hour format (e.g., '6:42 AM')."""
34
+ def format_time(iso_string: str, timezone: str = "UTC") -> str:
35
+ """Convert ISO 8601 datetime to 12-hour format in specified timezone.
36
+
37
+ Args:
38
+ iso_string: ISO 8601 datetime string
39
+ timezone: Timezone name (e.g., 'America/New_York'), defaults to UTC
40
+
41
+ Returns:
42
+ Formatted time string (e.g., '6:42 AM') or '--' on error
43
+ """
36
44
  if not iso_string:
37
45
  return "--"
46
+
38
47
  try:
48
+ # Parse the datetime
39
49
  dt = datetime.fromisoformat(iso_string.replace("Z", "+00:00"))
50
+
51
+ # Convert to local timezone if not UTC
52
+ if timezone != "UTC":
53
+ try:
54
+ import pytz
55
+
56
+ utc_dt = dt.replace(tzinfo=pytz.UTC)
57
+ local_tz = pytz.timezone(timezone)
58
+ local_dt = utc_dt.astimezone(local_tz)
59
+ return local_dt.strftime("%-I:%M %p")
60
+ except ImportError:
61
+ # pytz not installed, fall back to UTC
62
+ pass
63
+
40
64
  return dt.strftime("%-I:%M %p")
41
65
  except (ValueError, AttributeError):
42
66
  return "--"
43
67
 
68
+ # Try to import pytz for timezone conversion
69
+ pytz_available = False
70
+ if timezone != "UTC":
71
+ try:
72
+ import pytz
73
+
74
+ pytz_available = True
75
+ except ImportError:
76
+ pytz_available = False
77
+
78
+ try:
79
+ # Parse the datetime
80
+ dt = datetime.fromisoformat(iso_string.replace("Z", "+00:00"))
81
+
82
+ # Convert to local timezone if not UTC and pytz is available
83
+ if timezone != "UTC" and pytz_available:
84
+ utc_dt = dt.replace(tzinfo=pytz.UTC)
85
+ local_tz = pytz.timezone(timezone)
86
+ local_dt = utc_dt.astimezone(local_tz)
87
+ return local_dt.strftime("%-I:%M %p")
88
+
89
+ return dt.strftime("%-I:%M %p")
90
+ except (ValueError, AttributeError):
91
+ return "--"
92
+ try:
93
+ # Parse the datetime
94
+ dt = datetime.fromisoformat(iso_string.replace("Z", "+00:00"))
95
+
96
+ # Convert to local timezone if not UTC
97
+ if timezone != "UTC":
98
+ import pytz
99
+
100
+ utc_dt = dt.replace(tzinfo=pytz.UTC)
101
+ local_tz = pytz.timezone(timezone)
102
+ local_dt = utc_dt.astimezone(local_tz)
103
+ return local_dt.strftime("%-I:%M %p")
104
+
105
+ return dt.strftime("%-I:%M %p")
106
+ except (ValueError, AttributeError, ImportError):
107
+ # Fallback to UTC if pytz not available or other error
108
+ try:
109
+ dt = datetime.fromisoformat(iso_string.replace("Z", "+00:00"))
110
+ return dt.strftime("%-I:%M %p")
111
+ except (ValueError, AttributeError):
112
+ return "--"
113
+
44
114
 
45
115
  def get_temp_color(temp_val: float, unit="°F") -> str:
46
116
  """Returns a Rich color tag based on the temperature and unit."""
@@ -14,7 +14,8 @@ from isobar_cli.ui import (
14
14
  )
15
15
 
16
16
  app = typer.Typer(
17
- help=("Terminal weather focused on what it FEELS LIKE outside right now.")
17
+ help=("Terminal weather focused on what it FEELS LIKE outside right now."),
18
+ invoke_without_command=True,
18
19
  )
19
20
  console = Console()
20
21
 
@@ -25,6 +26,60 @@ def city_complete(incomplete: str):
25
26
  return [c for c in cached if c.lower().startswith(incomplete.lower())]
26
27
 
27
28
 
29
+ @app.callback()
30
+ def main(
31
+ ctx: typer.Context,
32
+ cities: Annotated[
33
+ Optional[list[str]],
34
+ typer.Argument(
35
+ help='City name(s) (detects automatically if omitted). Use quotes for multi-word cities (e.g. "New York").',
36
+ autocompletion=city_complete,
37
+ ),
38
+ ] = None,
39
+ forecast: Annotated[
40
+ bool,
41
+ typer.Option(
42
+ "--forecast",
43
+ "-f",
44
+ help="Show 7-day forecast after current conditions",
45
+ ),
46
+ ] = False,
47
+ hourly: Annotated[
48
+ bool,
49
+ typer.Option(
50
+ "--hourly",
51
+ "-H",
52
+ help="Show next 12 hours of weather",
53
+ ),
54
+ ] = False,
55
+ metric: Annotated[
56
+ bool,
57
+ typer.Option(
58
+ "--metric",
59
+ "-m",
60
+ help="Show weather in metric units (Celsius, km/h, mm)",
61
+ ),
62
+ ] = False,
63
+ ):
64
+ """
65
+ Get the weather and what it FEELS LIKE outside right now.
66
+
67
+ Examples:
68
+ isobar # Auto-detect location
69
+ isobar Chicago # Specific city
70
+ isobar London Tokyo Paris # Multiple cities
71
+ isobar --hourly # Next 12 hours
72
+ isobar --forecast # 7-day forecast
73
+ isobar --metric # Celsius, km/h, mm
74
+ """
75
+ # If a subcommand was called, don't run the default
76
+ if ctx.invoked_subcommand is not None:
77
+ return
78
+
79
+ # Run the main weather logic
80
+ _run_weather_logic(cities, forecast, hourly, metric)
81
+
82
+
28
83
  @app.command()
29
84
  def home(
30
85
  city: Annotated[
@@ -66,51 +121,14 @@ def home(
66
121
  console.print(f"[green]✓ Home city set to: {city}[/green]")
67
122
 
68
123
 
69
- @app.command()
70
- def main(
71
- cities: Annotated[
72
- Optional[list[str]],
73
- typer.Argument(
74
- help='City name(s) (detects automatically if omitted). Use quotes for multi-word cities (e.g. "New York").',
75
- autocompletion=city_complete,
76
- ),
77
- ] = None,
78
- forecast: Annotated[
79
- bool,
80
- typer.Option(
81
- "--forecast",
82
- "-f",
83
- help="Show 7-day forecast after current conditions",
84
- ),
85
- ] = False,
86
- hourly: Annotated[
87
- bool,
88
- typer.Option(
89
- "--hourly",
90
- "-H",
91
- help="Show next 12 hours of weather",
92
- ),
93
- ] = False,
94
- metric: Annotated[
95
- bool,
96
- typer.Option(
97
- "--metric",
98
- "-m",
99
- help="Show weather in metric units (Celsius, km/h, mm)",
100
- ),
101
- ] = False,
124
+ def _run_weather_logic(
125
+ cities: Optional[list[str]] = None,
126
+ forecast: bool = False,
127
+ hourly: bool = False,
128
+ metric: bool = False,
102
129
  ):
103
130
  """
104
- Get the weather and what it FEELS LIKE outside right now.
105
-
106
- Examples:
107
- isobar # Auto-detect location
108
- isobar Chicago # Single city
109
- isobar London Tokyo Paris # Multiple cities
110
- isobar "New York" # Quotes for multi-word cities
111
- isobar --forecast # Current + 7-day outlook
112
- isobar --hourly # Current + next 12 hours
113
- isobar --metric # Metric units
131
+ Core weather logic shared between default callback and main command.
114
132
  """
115
133
  # Resolve cities list
116
134
  cities_to_fetch = []
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: isobar-cli
3
- Version: 1.1.1
3
+ Version: 1.2.0
4
4
  Summary: A visually pleasing terminal weather tool focusing on Real Feel and Windchill.
5
5
  Author: Beau Bremer / KnowOneActual
6
6
  License-Expression: MIT
@@ -29,6 +29,8 @@ Provides-Extra: test
29
29
  Requires-Dist: pytest>=7.0.0; extra == "test"
30
30
  Requires-Dist: requests-mock>=1.11.0; extra == "test"
31
31
  Requires-Dist: pytest-cov>=4.1.0; extra == "test"
32
+ Provides-Extra: timezone
33
+ Requires-Dist: pytz>=2024.1; extra == "timezone"
32
34
  Dynamic: license-file
33
35
 
34
36
  # Isobar CLI
@@ -36,7 +38,7 @@ Dynamic: license-file
36
38
  ![CI](https://github.com/KnowOneActual/isobar-cli/actions/workflows/ci.yml/badge.svg)
37
39
  ![Coverage](https://img.shields.io/badge/coverage-98%25-green)
38
40
  [![PyPI version](https://badge.fury.io/py/isobar-cli.svg)](https://badge.fury.io/py/isobar-cli)
39
- ![Version](https://img.shields.io/badge/version-1.0.1-blue)
41
+ ![Version](https://img.shields.io/badge/version-1.2.0-blue)
40
42
  ![Ruff](https://img.shields.io/badge/linting-ruff-purple)
41
43
  ![Python](https://img.shields.io/badge/python-3.8%2B-blue)
42
44
  ![License](https://img.shields.io/badge/license-MIT-green)
@@ -80,7 +82,7 @@ Most weather apps overwhelm with data. Isobar strips away everything except what
80
82
  - **Temporal Context** — Comparison with previous day conditions 📈
81
83
  - **UV Index Monitoring** — Sun protection guidance with intensity levels ☀️
82
84
  - **Wind Gust Alerts** — Highlighting of significant gust events 💨⚠️
83
- - **Home City Persistence** — Set a default city with `isobar home "Your City"` 🏠
85
+ - **Home City Persistence** — Set a default city with `isobar home "Your City"` 🏠 *(Note: Due to a Typer limitation, this currently shows weather for "Home, Kansas" instead of setting home city. Manual config editing required.)*
84
86
 
85
87
  ## 🚀 Installation
86
88
 
@@ -124,12 +126,16 @@ isobar London Tokyo Paris # Multiple cities
124
126
  isobar "New York" # Use quotes for multi-word cities
125
127
 
126
128
  # Hourly outlook (next 12h)
127
- isobar --hourly
128
- isobar -H
129
+ isobar --hourly Chicago
130
+ isobar -H Chicago
129
131
 
130
132
  # 7-day forecast
131
- isobar --forecast
132
- isobar -f
133
+ isobar --forecast Chicago
134
+ isobar -f Chicago
135
+
136
+ # Note: Flags must come before city names
137
+ # ✅ isobar -H Chicago
138
+ # ❌ isobar Chicago -H (treats "-H" as a city name)
133
139
  isobar "San Francisco" --forecast
134
140
  isobar -f Sydney
135
141
 
@@ -145,6 +151,42 @@ isobar home --clear # Clear home city
145
151
  isobar # Uses home city if set (otherwise auto-detects)
146
152
  ```
147
153
 
154
+ ## ⚙️ Configuration
155
+
156
+ Isobar supports configuration via environment variables for advanced use cases:
157
+
158
+ ### API Endpoint Configuration
159
+ Customize API endpoints for different weather providers or testing:
160
+
161
+ ```bash
162
+ # Use custom weather APIs
163
+ export ISOBAR_GEOCODING_URL="https://custom-geocoding-api.example.com/v1/search"
164
+ export ISOBAR_WEATHER_URL="https://custom-weather-api.example.com/v1/forecast"
165
+ export ISOBAR_AQI_URL="https://custom-aqi-api.example.com/v1/air-quality"
166
+
167
+ # Run with custom endpoints
168
+ isobar "New York"
169
+ ```
170
+
171
+ ### Timezone Support
172
+ For enhanced timezone accuracy (optional):
173
+
174
+ ```bash
175
+ # Install optional timezone support
176
+ pip install isobar-cli[timezone]
177
+
178
+ # Sunrise/sunset will now display in local timezone
179
+ isobar London
180
+ ```
181
+
182
+ ### Debug Mode
183
+ Enable debug logging to stderr:
184
+
185
+ ```bash
186
+ # View API errors and debugging information
187
+ isobar "Test City" 2> debug.log
188
+ ```
189
+
148
190
  ## ⌨️ Shell Completion
149
191
 
150
192
  Isobar supports tab-completion for city names. To enable it for a shell:
@@ -212,6 +254,10 @@ Preparation Guidance:
212
254
  | `config.py` | Persistent home city configuration |
213
255
  | Enhanced `logic.py` | Preparation guidance, UV monitoring, gust alerts |
214
256
  | Updated `ui.py` | Contextual display of insights |
257
+ | **v1.2.0 Features** | **Security & Configuration** |
258
+ | Configurable API Endpoints | Environment variable support for custom APIs |
259
+ | Enhanced Error Handling | Specific exception catching with timeouts |
260
+ | Timezone Support | Optional `pytz` dependency for local time display |
215
261
 
216
262
  ## 🔒 Security
217
263
 
@@ -235,6 +281,7 @@ All security scans are integrated into the CI/CD pipeline and run on every push,
235
281
  ✅ **Phase 5 Complete** — Testing & Reliability
236
282
  ✅ **Phase 6 Complete** — Distribution (PyPI, Homebrew)
237
283
  ✅ **Phase 7 Complete** — Intuition & Analysis (v1.1.0)
284
+ ✅ **v1.2.0 Complete** — Security & Configuration Enhancements
238
285
  Refer to [ROADMAP.md](ROADMAP.md) and [CHANGELOG.md](CHANGELOG.md) for details.
239
286
 
240
287
  ## 🤝 Contributing
@@ -7,3 +7,6 @@ timezonefinder>=6.0.0
7
7
  pytest>=7.0.0
8
8
  requests-mock>=1.11.0
9
9
  pytest-cov>=4.1.0
10
+
11
+ [timezone]
12
+ pytz>=2024.1
@@ -212,88 +212,97 @@ def test_get_weather_data_request_exception(requests_mock):
212
212
 
213
213
 
214
214
  def test_main_auto_location_fail(monkeypatch):
215
- monkeypatch.setattr("isobar_cli.main.get_auto_location", lambda: None)
216
- units = WeatherUnits(temp="°F", wind="mph", precip="in")
217
215
  mock_weather = WeatherData(
218
- city="Chicago",
219
- temp=30.0,
220
- feels_like=25.0,
221
- wind_speed=10.0,
222
- humidity=50,
223
- precipitation=0.0,
216
+ city="Chicago, Illinois",
217
+ temp=75.2,
218
+ feels_like=78.5,
219
+ wind_speed=12.4,
220
+ humidity=65,
221
+ precipitation=0.1,
224
222
  weather_code=0,
225
- precip_prob=10,
226
- rainfall=0.0,
223
+ precip_prob=30,
224
+ rainfall=0.1,
227
225
  snowfall=0.0,
228
- sunrise="6:00 AM",
229
- sunset="6:00 PM",
226
+ sunrise="6:29 AM",
227
+ sunset="5:37 PM",
230
228
  forecast=[],
231
229
  hourly=[],
232
- units=units,
230
+ aqi=45,
231
+ units=WeatherUnits(temp="°F", wind="mph", precip="in"),
232
+ wind_gust=25.0,
233
+ uv_index=6.5,
234
+ previous_day_temp=70.0,
233
235
  )
236
+ monkeypatch.setattr("isobar_cli.main.get_auto_location", lambda: None)
237
+ monkeypatch.setattr("isobar_cli.main.get_home_city", lambda: None)
234
238
  monkeypatch.setattr(
235
239
  "isobar_cli.main.get_weather_data", lambda city, metric=False: mock_weather
236
240
  )
237
- result = runner.invoke(
238
- app, ["main"], color=False, env={"TERM": "dumb", "NO_COLOR": "1"}
239
- )
241
+ result = runner.invoke(app, [], color=False, env={"TERM": "dumb", "NO_COLOR": "1"})
240
242
  assert "Could not detect location" in result.output
241
243
  assert "Using Chicago as default" in result.output
242
244
 
243
245
 
244
246
  def test_main_auto_location_success(monkeypatch):
245
- monkeypatch.setattr("isobar_cli.main.get_auto_location", lambda: "New York")
246
- units = WeatherUnits(temp="°F", wind="mph", precip="in")
247
247
  mock_weather = WeatherData(
248
- city="New York",
249
- temp=30.0,
250
- feels_like=25.0,
251
- wind_speed=10.0,
252
- humidity=50,
248
+ city="New York, New York",
249
+ temp=68.0,
250
+ feels_like=70.0,
251
+ wind_speed=8.0,
252
+ humidity=60,
253
253
  precipitation=0.0,
254
254
  weather_code=0,
255
255
  precip_prob=10,
256
256
  rainfall=0.0,
257
257
  snowfall=0.0,
258
- sunrise="6:00 AM",
259
- sunset="6:00 PM",
258
+ sunrise="6:15 AM",
259
+ sunset="7:45 PM",
260
260
  forecast=[],
261
261
  hourly=[],
262
- units=units,
262
+ aqi=30,
263
+ units=WeatherUnits(temp="°F", wind="mph", precip="in"),
264
+ wind_gust=12.0,
265
+ uv_index=5.0,
266
+ previous_day_temp=65.0,
263
267
  )
268
+ monkeypatch.setattr("isobar_cli.main.get_auto_location", lambda: "New York")
269
+ monkeypatch.setattr("isobar_cli.main.get_home_city", lambda: None)
264
270
  monkeypatch.setattr(
265
271
  "isobar_cli.main.get_weather_data", lambda city, metric=False: mock_weather
266
272
  )
267
- result = runner.invoke(
268
- app, ["main"], color=False, env={"TERM": "dumb", "NO_COLOR": "1"}
269
- )
273
+ result = runner.invoke(app, [], color=False, env={"TERM": "dumb", "NO_COLOR": "1"})
270
274
  assert "Detected: New York" in result.output
271
275
 
272
276
 
273
277
  def test_main_city_option(monkeypatch):
274
278
  units = WeatherUnits(temp="°F", wind="mph", precip="in")
279
+ mock_weather = WeatherData(
280
+ city="Tokyo, Tokyo",
281
+ temp=60.0,
282
+ feels_like=62.0,
283
+ wind_speed=5.0,
284
+ humidity=50,
285
+ precipitation=0.0,
286
+ weather_code=1,
287
+ precip_prob=20,
288
+ rainfall=0.0,
289
+ snowfall=0.0,
290
+ sunrise="5:30 AM",
291
+ sunset="6:30 PM",
292
+ forecast=[],
293
+ hourly=[],
294
+ aqi=25,
295
+ units=units,
296
+ wind_gust=8.0,
297
+ uv_index=4.0,
298
+ previous_day_temp=58.0,
299
+ )
275
300
  monkeypatch.setattr(
276
301
  "isobar_cli.main.get_weather_data",
277
- lambda city, metric=False: WeatherData(
278
- city=city,
279
- temp=30.0,
280
- feels_like=25.0,
281
- wind_speed=10.0,
282
- humidity=50,
283
- precipitation=0.0,
284
- weather_code=0,
285
- precip_prob=10,
286
- rainfall=0.0,
287
- snowfall=0.0,
288
- sunrise="6:00 AM",
289
- sunset="6:00 PM",
290
- forecast=[],
291
- hourly=[],
292
- units=units,
293
- ),
302
+ lambda city, metric=False: mock_weather if city == "Tokyo" else None,
294
303
  )
295
304
  result = runner.invoke(
296
- app, ["main", "Tokyo"], color=False, env={"TERM": "dumb", "NO_COLOR": "1"}
305
+ app, ["Tokyo"], color=False, env={"TERM": "dumb", "NO_COLOR": "1"}
297
306
  )
298
307
  assert "Tokyo" in result.output
299
308
 
@@ -343,7 +352,7 @@ def test_main_with_flags(monkeypatch):
343
352
  # Multi-city
344
353
  result = runner.invoke(
345
354
  app,
346
- ["main", "City1", "City2"],
355
+ ["City1", "City2"],
347
356
  color=False,
348
357
  env={"TERM": "dumb", "NO_COLOR": "1"},
349
358
  )
@@ -354,7 +363,7 @@ def test_main_with_flags(monkeypatch):
354
363
  # Multi-city with flags
355
364
  result = runner.invoke(
356
365
  app,
357
- ["main", "City1", "City2", "--hourly"],
366
+ ["--hourly", "City1", "City2"],
358
367
  color=False,
359
368
  env={"TERM": "dumb", "NO_COLOR": "1"},
360
369
  )
@@ -364,7 +373,7 @@ def test_main_with_flags(monkeypatch):
364
373
  # Hourly
365
374
  result = runner.invoke(
366
375
  app,
367
- ["main", "CityH", "--hourly"],
376
+ ["--hourly", "CityH"],
368
377
  color=False,
369
378
  env={"TERM": "dumb", "NO_COLOR": "1"},
370
379
  )
@@ -374,7 +383,7 @@ def test_main_with_flags(monkeypatch):
374
383
  # Forecast
375
384
  result = runner.invoke(
376
385
  app,
377
- ["main", "CityF", "--forecast"],
386
+ ["--forecast", "CityF"],
378
387
  color=False,
379
388
  env={"TERM": "dumb", "NO_COLOR": "1"},
380
389
  )
@@ -9,16 +9,17 @@ runner = CliRunner()
9
9
 
10
10
  def test_main_help():
11
11
  result = runner.invoke(
12
- app, ["main", "--help"], color=False, env={"TERM": "dumb", "NO_COLOR": "1"}
12
+ app, ["--help"], color=False, env={"TERM": "dumb", "NO_COLOR": "1"}
13
13
  )
14
14
  assert result.exit_code == 0
15
- assert "Get the weather and what it FEELS LIKE" in result.output
15
+ assert "Terminal weather focused on what it FEELS LIKE" in result.output
16
16
 
17
17
 
18
18
  def test_main_metric_flag_exists():
19
19
  result = runner.invoke(
20
- app, ["main", "--help"], color=False, env={"TERM": "dumb", "NO_COLOR": "1"}
20
+ app, ["--help"], color=False, env={"TERM": "dumb", "NO_COLOR": "1"}
21
21
  )
22
+ assert result.exit_code == 0
22
23
  assert "--metric" in result.output
23
24
  assert "-m" in result.output
24
25
 
@@ -66,7 +67,7 @@ def mock_api(monkeypatch):
66
67
 
67
68
  def test_main_with_city(mock_api):
68
69
  result = runner.invoke(
69
- app, ["main", "Chicago"], color=False, env={"TERM": "dumb", "NO_COLOR": "1"}
70
+ app, ["Chicago"], color=False, env={"TERM": "dumb", "NO_COLOR": "1"}
70
71
  )
71
72
  assert result.exit_code == 0
72
73
 
@@ -74,7 +75,7 @@ def test_main_with_city(mock_api):
74
75
  def test_main_with_metric(mock_api):
75
76
  result = runner.invoke(
76
77
  app,
77
- ["main", "Chicago", "--metric"],
78
+ ["Chicago", "--metric"],
78
79
  color=False,
79
80
  env={"TERM": "dumb", "NO_COLOR": "1"},
80
81
  )
@@ -84,7 +85,7 @@ def test_main_with_metric(mock_api):
84
85
  def test_main_with_hourly(mock_api):
85
86
  result = runner.invoke(
86
87
  app,
87
- ["main", "Chicago", "--hourly"],
88
+ ["Chicago", "--hourly"],
88
89
  color=False,
89
90
  env={"TERM": "dumb", "NO_COLOR": "1"},
90
91
  )
@@ -94,7 +95,7 @@ def test_main_with_hourly(mock_api):
94
95
  def test_main_multiple_cities(mock_api):
95
96
  result = runner.invoke(
96
97
  app,
97
- ["main", "Chicago", "London"],
98
+ ["Chicago", "London"],
98
99
  color=False,
99
100
  env={"TERM": "dumb", "NO_COLOR": "1"},
100
101
  )
@@ -103,7 +104,7 @@ def test_main_multiple_cities(mock_api):
103
104
 
104
105
  def test_main_not_found_with_suggestions(mock_api):
105
106
  result = runner.invoke(
106
- app, ["main", "Unknown"], color=False, env={"TERM": "dumb", "NO_COLOR": "1"}
107
+ app, ["Unknown"], color=False, env={"TERM": "dumb", "NO_COLOR": "1"}
107
108
  )
108
109
  assert result.exit_code == 1
109
110
  assert "'Unknown' not found" in result.output
@@ -1 +0,0 @@
1
- __version__ = "0.6.2"
File without changes
File without changes
File without changes
File without changes