Skip to content

Commit d373a06

Browse files
committed
chore: restructure haaska
1 parent e63a459 commit d373a06

2 files changed

Lines changed: 132 additions & 112 deletions

File tree

haaska.py

Lines changed: 117 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -36,32 +36,124 @@
3636

3737
logger = logging.getLogger()
3838

39+
DEFAULT_TIMEOUT = 0.01
3940

40-
class HomeAssistant:
41-
"""Handles HTTP interactions with Home Assistant API.
4241

43-
This class manages a requests session configured for Home Assistant,
44-
including authentication, SSL settings, and API calls.
42+
class ConfigurationLoader:
43+
"""Loads configuration from a file."""
44+
45+
@staticmethod
46+
def load(filename: str) -> dict:
47+
"""Load configuration from a file."""
48+
try:
49+
with open(filename, encoding="utf-8") as f:
50+
return json.load(f)
51+
except json.JSONDecodeError as e:
52+
raise ValueError(f"Invalid JSON in config file {filename}: {e}")
53+
54+
55+
class Configuration:
56+
"""Loads and parses configuration from JSON file or dict.
57+
58+
Handles default values and type conversions for Home Assistant settings.
4559
"""
4660

47-
def __init__(self, config: "Configuration") -> None:
48-
"""Initialize the HomeAssistant client.
61+
def __init__(self, json: dict) -> None:
62+
"""Initialize configuration from file or dict."""
63+
self._json = json
64+
self.url = self.get_url(self.get(["url", "ha_url"]))
65+
self.ssl_verify = self.get(["ssl_verify", "ha_cert"], True)
66+
self.bearer_token = self.get(["bearer_token"], "")
67+
self.ssl_client = self.get(["ssl_client"], "")
68+
# Convert list to tuple for SSL client cert if provided
69+
if isinstance(self.ssl_client, list):
70+
self.ssl_client = tuple(self.ssl_client)
71+
self.debug = self.get(["debug"], False)
72+
73+
def get(self, keys: List[str], default: Any = None) -> Any:
74+
"""Retrieve value from config dict using multiple possible keys.
75+
76+
Args:
77+
keys (list): List of possible key names to check.
78+
default: Default value if none of the keys are found.
79+
80+
Returns:
81+
The value associated with the first matching key, or default.
82+
"""
83+
return next((self._json[key] for key in keys if key in self._json), default)
84+
85+
def get_url(self, url: str) -> str:
86+
"""Normalize Home Assistant base URL.
87+
88+
Removes '/api' suffix and trailing slashes.
89+
90+
Args:
91+
url (str): Raw URL from config.
92+
93+
Returns:
94+
str: Normalized base URL.
95+
96+
Raises:
97+
ValueError: If URL is missing.
98+
"""
99+
if not url:
100+
raise ValueError('Property "url" is missing in config')
101+
return url.replace("/api", "").rstrip("/")
102+
103+
104+
class SessionFactory:
105+
"""Factory for creating configured requests sessions."""
106+
107+
@staticmethod
108+
def create_session(config: Configuration) -> requests.Session:
109+
"""Create a configured requests session for Home Assistant API.
49110
50111
Args:
51-
config (Configuration): Configuration object containing API settings.
112+
config (Configuration): Configuration object with API settings.
113+
114+
Returns:
115+
requests.Session: Configured session ready for API calls.
52116
"""
53-
self.config = config
54-
self.session = requests.Session()
55-
# Set up session headers for authentication and content type
56-
self.session.headers.update(
117+
session = requests.Session()
118+
119+
# Set up authentication and headers
120+
session.headers.update(
57121
{
58122
"Authorization": f"Bearer {config.bearer_token}",
59123
"content-type": "application/json",
60-
"User-Agent": self.get_user_agent(),
124+
"User-Agent": SessionFactory._get_user_agent(),
61125
}
62126
)
63-
self.session.verify = config.ssl_verify
64-
self.session.cert = config.ssl_client
127+
128+
# Configure SSL settings
129+
session.verify = config.ssl_verify
130+
session.cert = config.ssl_client
131+
132+
return session
133+
134+
@staticmethod
135+
def _get_user_agent() -> str:
136+
"""Generate a user agent string for requests.
137+
138+
Returns:
139+
str: User agent string including AWS region and default requests UA.
140+
"""
141+
aws_region = os.environ.get("AWS_DEFAULT_REGION", "unknown")
142+
return f"Home Assistant Alexa Smart Home Skill - {aws_region} - {requests.utils.default_user_agent()}"
143+
144+
145+
class HomeAssistant:
146+
"""Handles HTTP interactions with Home Assistant API."""
147+
148+
def __init__(self, base_url: str, session: requests.Session) -> None:
149+
"""Initialize the HomeAssistant client.
150+
151+
Args:
152+
base_url (str): Base URL for HA instance.
153+
session (requests.Session, optional): Pre-configured session. If None, creates one.
154+
"""
155+
self.base_url = base_url
156+
self.session = session
65157

66158
def build_url(self, endpoint: str) -> str:
67159
"""Build the full API URL for a given endpoint.
@@ -72,15 +164,7 @@ def build_url(self, endpoint: str) -> str:
72164
Returns:
73165
str: The complete URL including base URL and '/api/'.
74166
"""
75-
return f"{self.config.url}/api/{endpoint}"
76-
77-
def get_user_agent(self) -> str:
78-
"""Generate a user agent string for requests.
79-
80-
Returns:
81-
str: User agent string including AWS region and default requests UA.
82-
"""
83-
return f"Home Assistant Alexa Smart Home Skill - {os.environ.get('AWS_DEFAULT_REGION')} - {requests.utils.default_user_agent()}"
167+
return f"{self.base_url}/api/{endpoint}"
84168

85169
def get(self, endpoint: str) -> Dict[str, Any]:
86170
"""Perform a GET request to the Home Assistant API.
@@ -98,9 +182,7 @@ def get(self, endpoint: str) -> Dict[str, Any]:
98182
r.raise_for_status()
99183
return r.json()
100184

101-
def post(
102-
self, endpoint: str, event: Dict[str, Any], wait: bool = False
103-
) -> Optional[Dict[str, Any]]:
185+
def post(self, endpoint: str, event: Dict[str, Any], wait: bool = False) -> Optional[Dict[str, Any]]:
104186
"""Perform a POST request to the Home Assistant API.
105187
106188
Args:
@@ -114,81 +196,19 @@ def post(
114196
Raises:
115197
requests.HTTPError: If the request fails (when waiting).
116198
"""
199+
url = self.build_url(endpoint)
200+
timeout = None if wait else DEFAULT_TIMEOUT
201+
117202
try:
118-
logger.debug("calling %s with %s", endpoint, event)
119-
r = self.session.post(
120-
self.build_url(endpoint), json=event, timeout=None if wait else 0.01
121-
)
122-
r.raise_for_status()
123-
return r.json()
203+
logger.debug("calling %s with %s", url, event)
204+
request = self.session.post(url, json=event, timeout=timeout)
205+
request.raise_for_status()
206+
return request.json()
124207
except requests.exceptions.ReadTimeout:
125208
logger.debug("request for %s sent without waiting for response", endpoint)
126209
return None
127210

128211

129-
class Configuration:
130-
"""Loads and parses configuration from JSON file or dict.
131-
132-
Handles default values and type conversions for Home Assistant settings.
133-
"""
134-
135-
def __init__(
136-
self, filename: Optional[str] = None, opts_dict: Optional[Dict[str, Any]] = None
137-
) -> None:
138-
"""Initialize configuration from file or dict.
139-
140-
Args:
141-
filename (str, optional): Path to JSON config file.
142-
opts_dict (dict, optional): Dict with config options.
143-
"""
144-
self._json = opts_dict or {}
145-
if filename:
146-
try:
147-
with open(filename, encoding="utf-8") as f:
148-
self._json = json.load(f)
149-
except json.JSONDecodeError as e:
150-
raise ValueError(f"Invalid JSON in config file {filename}: {e}") from e
151-
152-
self.url = self.get_url(self.get(["url", "ha_url"]))
153-
self.ssl_verify = self.get(["ssl_verify", "ha_cert"], True)
154-
self.bearer_token = self.get(["bearer_token"], "")
155-
self.ssl_client = self.get(["ssl_client"], "")
156-
# Convert list to tuple for SSL client cert if provided
157-
if isinstance(self.ssl_client, list):
158-
self.ssl_client = tuple(self.ssl_client)
159-
self.debug = self.get(["debug"], False)
160-
161-
def get(self, keys: List[str], default: Any = None) -> Any:
162-
"""Retrieve value from config dict using multiple possible keys.
163-
164-
Args:
165-
keys (list): List of possible key names to check.
166-
default: Default value if none of the keys are found.
167-
168-
Returns:
169-
The value associated with the first matching key, or default.
170-
"""
171-
return next((self._json[key] for key in keys if key in self._json), default)
172-
173-
def get_url(self, url: str) -> str:
174-
"""Normalize Home Assistant base URL.
175-
176-
Removes '/api' suffix and trailing slashes.
177-
178-
Args:
179-
url (str): Raw URL from config.
180-
181-
Returns:
182-
str: Normalized base URL.
183-
184-
Raises:
185-
ValueError: If URL is missing.
186-
"""
187-
if not url:
188-
raise ValueError('Property "url" is missing in config')
189-
return url.replace("/api", "").rstrip("/")
190-
191-
192212
def event_handler(event: Dict[str, Any], _context: Any) -> Optional[Dict[str, Any]]:
193213
"""AWS Lambda event handler for Alexa smart home events.
194214
@@ -201,8 +221,8 @@ def event_handler(event: Dict[str, Any], _context: Any) -> Optional[Dict[str, An
201221
Returns:
202222
dict or None: Response from Home Assistant API, or None if timed out.
203223
"""
204-
config = Configuration("config.json")
224+
config = Configuration(ConfigurationLoader.load("config.json"))
205225
if config.debug:
206226
logger.setLevel(logging.DEBUG)
207-
ha = HomeAssistant(config)
227+
ha = HomeAssistant(config.url, SessionFactory.create_session(config))
208228
return ha.post("alexa/smart_home", event, wait=True)

test.py

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,36 @@
11
import os
2+
23
import pytest
34

4-
from haaska import HomeAssistant, Configuration
5+
from haaska import Configuration, HomeAssistant, SessionFactory
56

67

78
@pytest.fixture
89
def configuration():
9-
return Configuration(opts_dict={
10-
"url": "http://localhost:8123",
11-
"bearer_token": "",
12-
"debug": False,
13-
"ssl_verify": True,
14-
"ssl_client": []
15-
})
10+
return Configuration(
11+
{
12+
"url": "http://localhost:8123",
13+
"bearer_token": "",
14+
"debug": False,
15+
"ssl_verify": True,
16+
"ssl_client": [],
17+
}
18+
)
1619

1720

1821
@pytest.fixture
1922
def home_assistant(configuration):
20-
return HomeAssistant(configuration)
23+
return HomeAssistant(configuration.url, SessionFactory.create_session(configuration))
2124

2225

2326
def test_ha_build_url(home_assistant):
2427
url = home_assistant.build_url("test")
2528
assert url == "http://localhost:8123/api/test"
2629

2730

28-
def test_get_user_agent(home_assistant):
31+
def test_session_factory_user_agent(configuration):
2932
os.environ["AWS_DEFAULT_REGION"] = "test"
30-
user_agent = home_assistant.get_user_agent()
33+
user_agent = SessionFactory.create_session(configuration).headers["User-Agent"]
3134
assert user_agent.startswith("Home Assistant Alexa Smart Home Skill - test - python-requests/")
3235

3336

@@ -38,10 +41,7 @@ def test_config_get(configuration):
3841

3942

4043
def test_config_get_url(configuration):
41-
test_urls = [
42-
"http://hass.example.com:8123",
43-
"http://hass.example.app"
44-
]
44+
test_urls = ["http://hass.example.com:8123", "http://hass.example.app"]
4545
for expected_url in test_urls:
4646
assert configuration.get_url(expected_url + "/") == expected_url
4747
assert configuration.get_url(expected_url + "/api") == expected_url

0 commit comments

Comments
 (0)