birtrashclient 0.1.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.
@@ -0,0 +1,33 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: actions/setup-python@v5
13
+ with:
14
+ python-version: "3.12"
15
+ - run: pip install hatch
16
+ - run: hatch build
17
+ - uses: actions/upload-artifact@v4
18
+ with:
19
+ name: dist
20
+ path: dist/
21
+
22
+ publish:
23
+ needs: build
24
+ runs-on: ubuntu-latest
25
+ environment: pypi
26
+ permissions:
27
+ id-token: write
28
+ steps:
29
+ - uses: actions/download-artifact@v4
30
+ with:
31
+ name: dist
32
+ path: dist/
33
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,19 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .eggs/
8
+
9
+ # Virtual environments
10
+ .venv/
11
+ venv/
12
+
13
+ # Tools
14
+ .mypy_cache/
15
+ .ruff_cache/
16
+ .pytest_cache/
17
+
18
+ # Secrets / local config
19
+ .env
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: birtrashclient
3
+ Version: 0.1.0
4
+ Summary: Async client library for the BIR Trash Collection API
5
+ Project-URL: Homepage, https://github.com/eirikgrindevoll/birtrashclient
6
+ Project-URL: Bug Tracker, https://github.com/eirikgrindevoll/birtrashclient/issues
7
+ License: MIT
8
+ Classifier: Framework :: AsyncIO
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Topic :: Home Automation
15
+ Requires-Python: >=3.11
16
+ Requires-Dist: aiohttp>=3.9
17
+ Description-Content-Type: text/markdown
18
+
19
+ # birtrashclient
20
+
21
+ Async Python client for the [BIR](https://bir.no) Trash Collection API.
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ pip install birtrashclient
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```python
32
+ import asyncio
33
+ from birtrashclient import BirTrashClient
34
+
35
+ async def main():
36
+ client = BirTrashClient(app_id="your_app_id", contractor_id="your_contractor_id")
37
+ await client.authenticate()
38
+
39
+ address_id = await client.search_address("Storgata 1, Bergen")
40
+ calendar = await client.get_calendar(address_id, "2024-01-01", "2024-12-31")
41
+
42
+ for entry in calendar:
43
+ print(entry)
44
+
45
+ await client.close()
46
+
47
+ asyncio.run(main())
48
+ ```
49
+
50
+ ## API
51
+
52
+ ### `BirTrashClient(app_id, contractor_id, ...)`
53
+
54
+ | Parameter | Type | Default | Description |
55
+ |-----------|------|---------|-------------|
56
+ | `app_id` | `str` | required | Application ID for API authentication |
57
+ | `contractor_id` | `str` | required | Contractor ID for API authentication |
58
+ | `request_timeout` | `int` | `10` | HTTP timeout in seconds |
59
+ | `session` | `aiohttp.ClientSession` | `None` | Optional session to reuse |
60
+ | `retries` | `int` | `3` | Retries on transient server errors |
61
+ | `backoff_factor` | `float` | `2.0` | Exponential backoff base multiplier |
62
+
63
+ ### Methods
64
+
65
+ - `authenticate()` — Fetch and store an auth token
66
+ - `search_address(address)` — Resolve a street address to a property ID
67
+ - `get_calendar(address_id, from_date, to_date)` — Get pickup schedule (dates as `YYYY-MM-DD`)
68
+ - `close()` — Close the underlying HTTP session
69
+
70
+ ## Exceptions
71
+
72
+ - `BirTrashAuthError` — Authentication failure
73
+ - `BirTrashConnectionError` — Connection or HTTP error after retries
74
+
75
+ ## License
76
+
77
+ MIT
@@ -0,0 +1,59 @@
1
+ # birtrashclient
2
+
3
+ Async Python client for the [BIR](https://bir.no) Trash Collection API.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install birtrashclient
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ import asyncio
15
+ from birtrashclient import BirTrashClient
16
+
17
+ async def main():
18
+ client = BirTrashClient(app_id="your_app_id", contractor_id="your_contractor_id")
19
+ await client.authenticate()
20
+
21
+ address_id = await client.search_address("Storgata 1, Bergen")
22
+ calendar = await client.get_calendar(address_id, "2024-01-01", "2024-12-31")
23
+
24
+ for entry in calendar:
25
+ print(entry)
26
+
27
+ await client.close()
28
+
29
+ asyncio.run(main())
30
+ ```
31
+
32
+ ## API
33
+
34
+ ### `BirTrashClient(app_id, contractor_id, ...)`
35
+
36
+ | Parameter | Type | Default | Description |
37
+ |-----------|------|---------|-------------|
38
+ | `app_id` | `str` | required | Application ID for API authentication |
39
+ | `contractor_id` | `str` | required | Contractor ID for API authentication |
40
+ | `request_timeout` | `int` | `10` | HTTP timeout in seconds |
41
+ | `session` | `aiohttp.ClientSession` | `None` | Optional session to reuse |
42
+ | `retries` | `int` | `3` | Retries on transient server errors |
43
+ | `backoff_factor` | `float` | `2.0` | Exponential backoff base multiplier |
44
+
45
+ ### Methods
46
+
47
+ - `authenticate()` — Fetch and store an auth token
48
+ - `search_address(address)` — Resolve a street address to a property ID
49
+ - `get_calendar(address_id, from_date, to_date)` — Get pickup schedule (dates as `YYYY-MM-DD`)
50
+ - `close()` — Close the underlying HTTP session
51
+
52
+ ## Exceptions
53
+
54
+ - `BirTrashAuthError` — Authentication failure
55
+ - `BirTrashConnectionError` — Connection or HTTP error after retries
56
+
57
+ ## License
58
+
59
+ MIT
@@ -0,0 +1,9 @@
1
+ """BIR Trash Collection API client library."""
2
+
3
+ from .client import BirTrashAuthError, BirTrashClient, BirTrashConnectionError
4
+
5
+ __all__ = [
6
+ "BirTrashClient",
7
+ "BirTrashAuthError",
8
+ "BirTrashConnectionError",
9
+ ]
@@ -0,0 +1,247 @@
1
+ """Client library for the BIR Trash Collection API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import re
8
+ from typing import Any
9
+
10
+ import aiohttp
11
+
12
+ _LOGGER = logging.getLogger(__name__)
13
+
14
+ TRANSIENT_STATUS_CODES = {500, 502, 503, 504}
15
+ DEFAULT_RETRIES = 3
16
+ DEFAULT_BACKOFF = 2.0
17
+
18
+
19
+ class BirTrashAuthError(Exception):
20
+ """Exception raised when authentication fails."""
21
+
22
+
23
+ class BirTrashConnectionError(Exception):
24
+ """Exception raised when a connection error occurs."""
25
+
26
+
27
+ class BirTrashClient:
28
+ """Async client for the BIR Trash Collection API."""
29
+
30
+ def __init__(
31
+ self,
32
+ app_id: str,
33
+ contractor_id: str,
34
+ request_timeout: int = 10,
35
+ session: aiohttp.ClientSession | None = None,
36
+ retries: int = DEFAULT_RETRIES,
37
+ backoff_factor: float = DEFAULT_BACKOFF,
38
+ ) -> None:
39
+ """Initialize the client.
40
+
41
+ Args:
42
+ app_id: The application ID for API authentication.
43
+ contractor_id: The contractor ID for API authentication.
44
+ request_timeout: Timeout in seconds for HTTP requests.
45
+ session: An optional aiohttp ClientSession to reuse.
46
+ retries: Number of retries for transient server errors.
47
+ backoff_factor: Base delay multiplier for exponential backoff.
48
+ """
49
+ self.base_url = "https://webservice.bir.no/api"
50
+ self.request_timeout = aiohttp.ClientTimeout(total=request_timeout)
51
+ self.app_id = app_id
52
+ self.contractor_id = contractor_id
53
+ self.token: str | None = None
54
+ self._session = session
55
+ self._close_session = False
56
+ self.retries = retries
57
+ self.backoff_factor = backoff_factor
58
+
59
+ async def _get_session(self) -> aiohttp.ClientSession:
60
+ """Return the active session, creating one if needed."""
61
+ if self._session is None or self._session.closed:
62
+ self._session = aiohttp.ClientSession()
63
+ self._close_session = True
64
+ return self._session
65
+
66
+ async def _request_with_retry(
67
+ self,
68
+ method: str,
69
+ url: str,
70
+ **kwargs: Any,
71
+ ) -> Any:
72
+ """Execute an HTTP request with retry logic for transient errors.
73
+
74
+ Args:
75
+ method: The HTTP method (get, post, etc.).
76
+ url: The full URL to request.
77
+ **kwargs: Additional arguments passed to the aiohttp request.
78
+
79
+ Returns:
80
+ The parsed JSON response.
81
+
82
+ Raises:
83
+ BirTrashAuthError: On 401 after re-authentication also fails.
84
+ BirTrashConnectionError: After all retries are exhausted.
85
+ """
86
+ session = await self._get_session()
87
+ last_exception: Exception | None = None
88
+
89
+ for attempt in range(self.retries + 1):
90
+ try:
91
+ async with session.request(
92
+ method,
93
+ url,
94
+ timeout=self.request_timeout,
95
+ **kwargs,
96
+ ) as response:
97
+ if response.status == 401:
98
+ _LOGGER.debug(
99
+ "Token expired (attempt %d), re-authenticating",
100
+ attempt + 1,
101
+ )
102
+ await self.authenticate()
103
+ if "headers" in kwargs and kwargs["headers"]:
104
+ kwargs["headers"]["Token"] = self.token
105
+ continue
106
+
107
+ if response.status in TRANSIENT_STATUS_CODES:
108
+ delay = self.backoff_factor * (2 ** attempt)
109
+ _LOGGER.warning(
110
+ "Server returned %d (attempt %d/%d), "
111
+ "retrying in %.1f s",
112
+ response.status,
113
+ attempt + 1,
114
+ self.retries + 1,
115
+ delay,
116
+ )
117
+ last_exception = BirTrashConnectionError(
118
+ f"Server returned {response.status}"
119
+ )
120
+ await asyncio.sleep(delay)
121
+ continue
122
+
123
+ response.raise_for_status()
124
+ return await response.json()
125
+
126
+ except (aiohttp.ClientError, asyncio.TimeoutError) as err:
127
+ delay = self.backoff_factor * (2 ** attempt)
128
+ _LOGGER.warning(
129
+ "Request failed (attempt %d/%d): %s, retrying in %.1f s",
130
+ attempt + 1,
131
+ self.retries + 1,
132
+ err,
133
+ delay,
134
+ )
135
+ last_exception = err
136
+ if attempt < self.retries:
137
+ await asyncio.sleep(delay)
138
+
139
+ raise BirTrashConnectionError(
140
+ f"Request failed after {self.retries + 1} attempts"
141
+ ) from last_exception
142
+
143
+ async def authenticate(self) -> None:
144
+ """Authenticate with the BIR API and store the token.
145
+
146
+ Raises:
147
+ BirTrashAuthError: If the authentication request fails.
148
+ """
149
+ session = await self._get_session()
150
+ try:
151
+ async with session.post(
152
+ f"{self.base_url}/login",
153
+ json={
154
+ "applikasjonsId": self.app_id,
155
+ "oppdragsgiverId": self.contractor_id,
156
+ },
157
+ timeout=self.request_timeout,
158
+ ) as response:
159
+ response.raise_for_status()
160
+ self.token = response.headers["Token"]
161
+ except (aiohttp.ClientError, asyncio.TimeoutError) as err:
162
+ raise BirTrashAuthError(
163
+ f"Authentication failed: {err}"
164
+ ) from err
165
+
166
+ @staticmethod
167
+ def _normalize_address(address: str) -> str:
168
+ """Insert a space between street number and unit letter if missing.
169
+
170
+ Converts e.g. '46J' -> '46 J' to match the API's expected format.
171
+ """
172
+ return re.sub(r"(\d)([A-Za-z])$", r"\1 \2", address.strip())
173
+
174
+ async def search_addresses(self, address: str) -> list[dict[str, Any]]:
175
+ """Search for an address and return all matching properties.
176
+
177
+ Useful for Home Assistant config flows where the user selects
178
+ from a list of matching properties (e.g. multiple units at one
179
+ street number).
180
+
181
+ Args:
182
+ address: The street address to search for.
183
+
184
+ Returns:
185
+ A list of property dicts, each containing at minimum 'id'
186
+ and 'adresse' keys.
187
+
188
+ Raises:
189
+ BirTrashConnectionError: If the request fails after retries.
190
+ """
191
+ return await self._request_with_retry(
192
+ "get",
193
+ f"{self.base_url}/eiendommer",
194
+ params={"adresse": self._normalize_address(address)},
195
+ headers={"Token": self.token},
196
+ )
197
+
198
+ async def search_address(self, address: str) -> str:
199
+ """Search for an address and return the corresponding property ID.
200
+
201
+ The address is normalized before searching — a space is inserted
202
+ between the street number and unit letter if missing
203
+ (e.g. '46J' becomes '46 J').
204
+
205
+ Args:
206
+ address: The street address to search for.
207
+
208
+ Returns:
209
+ The property ID string for the first matching property.
210
+
211
+ Raises:
212
+ BirTrashConnectionError: If the request fails after retries.
213
+ """
214
+ result = await self.search_addresses(address)
215
+ return result[0].get("id")
216
+
217
+ async def get_calendar(
218
+ self, address_id: str, from_date: str, to_date: str
219
+ ) -> list[dict[str, Any]]:
220
+ """Get the pickup calendar for the provided address ID.
221
+
222
+ Args:
223
+ address_id: The property ID to query.
224
+ from_date: The start date in YYYY-MM-DD format.
225
+ to_date: The end date in YYYY-MM-DD format.
226
+
227
+ Returns:
228
+ A list of pickup schedule dictionaries.
229
+
230
+ Raises:
231
+ BirTrashConnectionError: If the request fails after retries.
232
+ """
233
+ return await self._request_with_retry(
234
+ "get",
235
+ f"{self.base_url}/tomminger",
236
+ params={
237
+ "datoFra": from_date,
238
+ "datoTil": to_date,
239
+ "eiendomId": address_id,
240
+ },
241
+ headers={"Token": self.token},
242
+ )
243
+
244
+ async def close(self) -> None:
245
+ """Close the underlying session if we own it."""
246
+ if self._session and self._close_session:
247
+ await self._session.close()
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "birtrashclient"
7
+ version = "0.1.0"
8
+ description = "Async client library for the BIR Trash Collection API"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.11"
12
+ dependencies = [
13
+ "aiohttp>=3.9",
14
+ ]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: OS Independent",
21
+ "Framework :: AsyncIO",
22
+ "Topic :: Home Automation",
23
+ ]
24
+
25
+ [project.urls]
26
+ "Homepage" = "https://github.com/eirikgrindevoll/birtrashclient"
27
+ "Bug Tracker" = "https://github.com/eirikgrindevoll/birtrashclient/issues"
@@ -0,0 +1,53 @@
1
+ """Integration test against the real BIR API.
2
+
3
+ Usage:
4
+ BIR_APP_ID=xxx BIR_CONTRACTOR_ID=yyy BIR_ADDRESS="Storgata 1" python test_integration.py
5
+ """
6
+
7
+ import asyncio
8
+ import os
9
+ from datetime import date, timedelta
10
+
11
+ from birtrashclient import BirTrashAuthError, BirTrashClient, BirTrashConnectionError
12
+
13
+
14
+ async def main() -> None:
15
+ app_id = os.environ.get("BIR_APP_ID")
16
+ contractor_id = os.environ.get("BIR_CONTRACTOR_ID")
17
+ address = os.environ.get("BIR_ADDRESS")
18
+
19
+ if not app_id or not contractor_id or not address:
20
+ raise SystemExit(
21
+ "Set BIR_APP_ID, BIR_CONTRACTOR_ID, and BIR_ADDRESS environment variables."
22
+ )
23
+
24
+ client = BirTrashClient(app_id=app_id, contractor_id=contractor_id)
25
+
26
+ try:
27
+ print("Authenticating...")
28
+ await client.authenticate()
29
+ print(f" Token: {client.token[:20]}...")
30
+
31
+ print(f"\nSearching for address: {address!r}")
32
+ address_id = await client.search_address(address)
33
+ print(f" Property ID: {address_id}")
34
+
35
+ from_date = date.today().isoformat()
36
+ to_date = (date.today() + timedelta(days=90)).isoformat()
37
+ print(f"\nFetching calendar ({from_date} -> {to_date})...")
38
+ calendar = await client.get_calendar(address_id, from_date, to_date)
39
+ print(f" {len(calendar)} pickup(s) found:")
40
+ for entry in calendar:
41
+ print(f" {entry}")
42
+
43
+ except BirTrashAuthError as err:
44
+ print(f"Auth error: {err}")
45
+ raise SystemExit(1)
46
+ except BirTrashConnectionError as err:
47
+ print(f"Connection error: {err}")
48
+ raise SystemExit(1)
49
+ finally:
50
+ await client.close()
51
+
52
+
53
+ asyncio.run(main())