onspy 0.1.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.
onspy/__init__.py ADDED
@@ -0,0 +1,56 @@
1
+ """
2
+ onspy: Python client for the Office of National Statistics (ONS) API
3
+
4
+ This package provides client functions for accessing the Office of National Statistics API
5
+ at https://api.beta.ons.gov.uk/v1.
6
+ """
7
+
8
+ import logging
9
+ import os
10
+
11
+ # Configure logging
12
+ logger = logging.getLogger(__name__)
13
+ logger.setLevel(logging.INFO)
14
+
15
+ # Add console handler if not already added
16
+ if not logger.handlers:
17
+ console_handler = logging.StreamHandler()
18
+ console_handler.setLevel(logging.INFO)
19
+ formatter = logging.Formatter(
20
+ "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
21
+ )
22
+ console_handler.setFormatter(formatter)
23
+ logger.addHandler(console_handler)
24
+
25
+ # Enable debug logging if ONS_DEBUG is set
26
+ if os.environ.get("ONS_DEBUG", "").lower() in ("1", "true", "yes"):
27
+ logger.setLevel(logging.DEBUG)
28
+ for handler in logger.handlers:
29
+ handler.setLevel(logging.DEBUG)
30
+ logger.debug("Debug logging enabled for onspy")
31
+
32
+ from .datasets import (
33
+ ons_datasets,
34
+ ons_ids,
35
+ ons_desc,
36
+ ons_editions,
37
+ ons_latest_href,
38
+ ons_latest_version,
39
+ ons_latest_edition,
40
+ ons_find_latest_version_across_editions,
41
+ )
42
+ from .get import ons_get, ons_get_obs, ons_dim, ons_dim_opts, ons_meta
43
+ from .code_lists import (
44
+ ons_codelists,
45
+ ons_codelist,
46
+ ons_codelist_editions,
47
+ ons_codelist_edition,
48
+ ons_codes,
49
+ ons_code,
50
+ ons_code_dataset,
51
+ )
52
+ from .search import ons_search
53
+ from .browse import ons_browse, ons_browse_qmi
54
+
55
+ __version__ = "0.1.0"
56
+ __author__ = "Joe Wait"
onspy/browse.py ADDED
@@ -0,0 +1,85 @@
1
+ """
2
+ Browser functionality for ONS.
3
+
4
+ This module provides functions to quickly open ONS webpages in a browser.
5
+ """
6
+
7
+ import webbrowser
8
+ from typing import Optional
9
+
10
+ from .datasets import ons_datasets, assert_valid_id, id_number
11
+
12
+
13
+ def ons_browse() -> str:
14
+ """Quickly browse to ONS' developer webpage.
15
+
16
+ This function opens the ONS developer webpage in a browser.
17
+
18
+ Returns:
19
+ The URL of the webpage
20
+
21
+ Examples:
22
+ >>> import onspy
23
+ >>> onspy.ons_browse()
24
+ """
25
+ url = "https://developer.ons.gov.uk/"
26
+ _open_url(url)
27
+ return url
28
+
29
+
30
+ def ons_browse_qmi(id: str = None) -> Optional[str]:
31
+ """Quickly browse to dataset's Quality and Methodology Information (QMI).
32
+
33
+ This function opens the QMI webpage for a dataset in a browser.
34
+
35
+ Args:
36
+ id: Dataset ID
37
+
38
+ Returns:
39
+ The URL of the webpage, or None if the dataset is not found
40
+
41
+ Examples:
42
+ >>> import onspy
43
+ >>> onspy.ons_browse_qmi("cpih01")
44
+ """
45
+ datasets = ons_datasets()
46
+ if datasets is None:
47
+ return None
48
+
49
+ if not assert_valid_id(id, datasets):
50
+ return None
51
+
52
+ idx = id_number(id, datasets)
53
+
54
+ # Handle nested dictionary
55
+ try:
56
+ if hasattr(datasets.iloc[idx], "qmi") and hasattr(
57
+ datasets.iloc[idx].qmi, "href"
58
+ ):
59
+ url = datasets.iloc[idx].qmi.href
60
+ elif isinstance(datasets.iloc[idx].get("qmi", {}), dict):
61
+ url = datasets.iloc[idx]["qmi"].get("href", None)
62
+ else:
63
+ return None
64
+
65
+ if url:
66
+ _open_url(url)
67
+ return url
68
+ return None
69
+ except (AttributeError, KeyError, IndexError):
70
+ return None
71
+
72
+
73
+ def _open_url(url: str, open_browser: bool = True) -> str:
74
+ """Open a URL in the default browser.
75
+
76
+ Args:
77
+ url: The URL to open
78
+ open_browser: Whether to actually open the browser (default: True)
79
+
80
+ Returns:
81
+ The URL
82
+ """
83
+ if open_browser:
84
+ webbrowser.open(url)
85
+ return url
onspy/client.py ADDED
@@ -0,0 +1,209 @@
1
+ """
2
+ ONS API Client module.
3
+
4
+ This module provides a centralized client class for making requests to the ONS API.
5
+ """
6
+
7
+ import logging
8
+ import requests
9
+ import json
10
+ from typing import Optional, Dict, Any
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class ONSClient:
16
+ """Client for interacting with the Office of National Statistics API."""
17
+
18
+ # API Constants
19
+ EMPTY = ""
20
+ ENDPOINT = "https://api.beta.ons.gov.uk/v1"
21
+ # Identify your bot
22
+ USER_AGENT = (
23
+ "onspy/0.1.0 (MyOrganisation contact@myorg.com +http://www.myorg.com/bot.html)"
24
+ )
25
+
26
+ def __init__(self, endpoint: Optional[str] = None):
27
+ """Initialize the ONS client.
28
+
29
+ Args:
30
+ endpoint: Custom API endpoint URL (optional)
31
+ """
32
+ self.endpoint = endpoint or self.ENDPOINT
33
+ self._session = requests.Session()
34
+ self._session.headers.update(self._get_browser_headers())
35
+
36
+ def _get_browser_headers(self) -> Dict[str, str]:
37
+ """Get browser-like headers to help with HTTP requests.
38
+
39
+ Returns:
40
+ Dictionary of headers that mimic a browser
41
+ """
42
+ return {
43
+ "User-Agent": self.USER_AGENT,
44
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
45
+ "Accept-Language": "en-US,en;q=0.5",
46
+ "Accept-Encoding": "gzip, deflate, br",
47
+ "Connection": "keep-alive",
48
+ "Upgrade-Insecure-Requests": "1",
49
+ "Pragma": "no-cache",
50
+ "Cache-Control": "no-cache",
51
+ }
52
+
53
+ def has_internet(self) -> bool:
54
+ """Check if internet connection is available.
55
+
56
+ Returns:
57
+ bool: True if internet connection is available, False otherwise
58
+ """
59
+ try:
60
+ # Try to connect to a widely available service
61
+ self._session.get("https://www.google.com", timeout=5)
62
+ return True
63
+ except requests.ConnectionError:
64
+ return False
65
+
66
+ def build_url(self, path_segments: Dict[str, Optional[str]]) -> str:
67
+ """Build a request URL from path segments.
68
+
69
+ Args:
70
+ path_segments: Dictionary mapping path segment names to values
71
+
72
+ Returns:
73
+ Full request URL
74
+ """
75
+ # Build path from segments
76
+ path_parts = []
77
+ for key, value in path_segments.items():
78
+ if value is None:
79
+ continue
80
+ elif value == self.EMPTY:
81
+ path_parts.append(key)
82
+ else:
83
+ path_parts.append(f"{key}/{value}")
84
+
85
+ path = "/".join(path_parts)
86
+ url = f"{self.endpoint}/{path}"
87
+
88
+ logger.debug(f"Built URL: {url}")
89
+ return url
90
+
91
+ def make_request(
92
+ self,
93
+ url: str,
94
+ limit: Optional[int] = None,
95
+ offset: Optional[int] = None,
96
+ **kwargs,
97
+ ) -> Optional[requests.Response]:
98
+ """Make HTTP request to the ONS API.
99
+
100
+ Args:
101
+ url: Request URL
102
+ limit: Number of records to return (optional)
103
+ offset: Position in the dataset to start from (optional)
104
+ **kwargs: Additional arguments to pass to requests.get
105
+
106
+ Returns:
107
+ Response object if successful, None otherwise
108
+ """
109
+ logger.debug(f"Making request to URL: {url}")
110
+ if limit is not None:
111
+ logger.debug(f"With limit: {limit}")
112
+ if offset is not None:
113
+ logger.debug(f"With offset: {offset}")
114
+
115
+ if not self.has_internet():
116
+ logger.error("Unable to connect: No internet connection available")
117
+ print(
118
+ "Unable to connect: Please ensure you have an active internet connection or access through a secure connection."
119
+ )
120
+ return None
121
+
122
+ try:
123
+ # Prepare parameters
124
+ params = {}
125
+ if limit is not None:
126
+ params["limit"] = limit
127
+ if offset is not None:
128
+ params["offset"] = offset
129
+
130
+ logger.debug(f"Request params: {params}")
131
+
132
+ # Try up to 3 times with exponential backoff
133
+ for attempt in range(3):
134
+ try:
135
+ logger.debug(f"Attempt {attempt+1}/3")
136
+
137
+ response = self._session.get(
138
+ url, params=params, timeout=30, **kwargs
139
+ )
140
+
141
+ logger.debug(f"Response status code: {response.status_code}")
142
+
143
+ response.raise_for_status() # Raise exception for HTTP errors
144
+
145
+ if response.status_code == 200:
146
+ logger.debug(
147
+ f"Request successful. Content length: {len(response.content)}"
148
+ )
149
+
150
+ return response
151
+
152
+ except (
153
+ requests.exceptions.RequestException,
154
+ requests.exceptions.HTTPError,
155
+ ) as e:
156
+ logger.warning(f"Request failed: {e}")
157
+
158
+ if attempt == 2: # Last attempt
159
+ logger.error(f"Request failed after 3 attempts: {e}")
160
+ print(f"Request failed: {e}")
161
+ return None
162
+
163
+ logger.debug(f"Retrying...")
164
+ continue # Try again
165
+
166
+ except Exception as err:
167
+ logger.error(f"Unexpected error during request: {err}", exc_info=True)
168
+ print(f"Error: {err}")
169
+ return None
170
+
171
+ def process_response(self, response: requests.Response) -> Dict[str, Any]:
172
+ """Process HTTP response and convert to JSON.
173
+
174
+ Args:
175
+ response: HTTP response object
176
+
177
+ Returns:
178
+ JSON content as dictionary
179
+ """
180
+ try:
181
+ json_data = response.json()
182
+ if logger.isEnabledFor(logging.DEBUG):
183
+ logger.debug(f"Response JSON keys: {list(json_data.keys())}")
184
+ if "items" in json_data:
185
+ logger.debug(f"Number of items: {len(json_data['items'])}")
186
+ if len(json_data["items"]) > 0:
187
+ logger.debug(
188
+ f"First item keys: {list(json_data['items'][0].keys())}"
189
+ )
190
+ return json_data
191
+ except json.JSONDecodeError:
192
+ logger.error("Error decoding JSON response", exc_info=True)
193
+ print("Error decoding JSON response")
194
+ return {}
195
+
196
+ @classmethod
197
+ def get_instance(cls) -> "ONSClient":
198
+ """Get a singleton instance of the ONS client.
199
+
200
+ Returns:
201
+ ONSClient instance
202
+ """
203
+ if not hasattr(cls, "_instance"):
204
+ cls._instance = cls()
205
+ return cls._instance
206
+
207
+
208
+ # Create a default client instance
209
+ default_client = ONSClient.get_instance()
onspy/code_lists.py ADDED
@@ -0,0 +1,300 @@
1
+ """
2
+ Explore codes and lists.
3
+
4
+ This module provides functions to get details about codes and code lists stored by ONS.
5
+ Codes are used to provide a common definition when presenting statistics with related categories.
6
+ """
7
+
8
+ from typing import Optional, List, Dict, Any
9
+
10
+ from .utils import (
11
+ build_base_request,
12
+ make_request,
13
+ process_response,
14
+ EMPTY,
15
+ )
16
+
17
+
18
+ def ons_codelists() -> Optional[List[str]]:
19
+ """Get a list of all available code lists.
20
+
21
+ Returns:
22
+ List of code list IDs, or None if the request fails
23
+
24
+ Examples:
25
+ >>> import onspy
26
+ >>> onspy.ons_codelists()
27
+ """
28
+ req = build_base_request(**{"code-lists": EMPTY})
29
+ res = make_request(req, limit=80)
30
+ if res is None:
31
+ return None
32
+
33
+ raw = process_response(res)
34
+
35
+ # Extract IDs from items
36
+ try:
37
+ return [item["links"]["self"]["id"] for item in raw.get("items", [])]
38
+ except (KeyError, TypeError):
39
+ return []
40
+
41
+
42
+ def assert_valid_codeid(id: str) -> bool:
43
+ """Check if a code list ID is valid.
44
+
45
+ Args:
46
+ id: Code list ID
47
+
48
+ Returns:
49
+ True if valid, raises ValueError otherwise
50
+ """
51
+ if id is None:
52
+ raise ValueError("You must specify a 'code_id', see ons_codelists()")
53
+
54
+ ids = ons_codelists()
55
+ if ids is None:
56
+ return False
57
+
58
+ if id not in ids:
59
+ raise ValueError(f"Invalid code_id '{id}'. See ons_codelists() for valid IDs.")
60
+
61
+ return True
62
+
63
+
64
+ def ons_codelist(code_id: str = None) -> Optional[Dict[str, Any]]:
65
+ """Get details for a specific code list.
66
+
67
+ Args:
68
+ code_id: Code list ID
69
+
70
+ Returns:
71
+ Dictionary with code list details, or None if the request fails
72
+
73
+ Examples:
74
+ >>> import onspy
75
+ >>> onspy.ons_codelist(code_id="quarter")
76
+ """
77
+ if not assert_valid_codeid(code_id):
78
+ return None
79
+
80
+ req = build_base_request(**{"code-lists": code_id})
81
+ res = make_request(req)
82
+ if res is None:
83
+ return None
84
+
85
+ return process_response(res)
86
+
87
+
88
+ def ons_codelist_editions(code_id: str = None) -> Optional[List[Dict[str, Any]]]:
89
+ """Get editions for a code list.
90
+
91
+ Args:
92
+ code_id: Code list ID
93
+
94
+ Returns:
95
+ List of editions, or None if the request fails
96
+
97
+ Examples:
98
+ >>> import onspy
99
+ >>> onspy.ons_codelist_editions(code_id="quarter")
100
+ """
101
+ if not assert_valid_codeid(code_id):
102
+ return None
103
+
104
+ req = build_base_request(**{"code-lists": code_id, "editions": EMPTY})
105
+ res = make_request(req)
106
+ if res is None:
107
+ return None
108
+
109
+ raw = process_response(res)
110
+ return raw.get("items", [])
111
+
112
+
113
+ def assert_valid_edition(code_id: str, edition: str) -> bool:
114
+ """Check if an edition is valid for a code list.
115
+
116
+ Args:
117
+ code_id: Code list ID
118
+ edition: Edition name
119
+
120
+ Returns:
121
+ True if valid, raises ValueError otherwise
122
+ """
123
+ if edition is None:
124
+ raise ValueError("You must specify an 'edition', see ons_codelist_editions()")
125
+
126
+ editions = ons_codelist_editions(code_id)
127
+ if editions is None:
128
+ return False
129
+
130
+ edition_names = [e.get("edition", "") for e in editions]
131
+ if edition not in edition_names:
132
+ raise ValueError(
133
+ f"Invalid edition '{edition}'. Valid editions are: {', '.join(edition_names)}"
134
+ )
135
+
136
+ return True
137
+
138
+
139
+ def ons_codelist_edition(
140
+ code_id: str = None, edition: str = None
141
+ ) -> Optional[Dict[str, Any]]:
142
+ """Get details for a specific edition of a code list.
143
+
144
+ Args:
145
+ code_id: Code list ID
146
+ edition: Edition name
147
+
148
+ Returns:
149
+ Dictionary with edition details, or None if the request fails
150
+
151
+ Examples:
152
+ >>> import onspy
153
+ >>> onspy.ons_codelist_edition(code_id="quarter", edition="one-off")
154
+ """
155
+ if not assert_valid_codeid(code_id):
156
+ return None
157
+
158
+ if not assert_valid_edition(code_id, edition):
159
+ return None
160
+
161
+ req = build_base_request(**{"code-lists": code_id, "editions": edition})
162
+ res = make_request(req)
163
+ if res is None:
164
+ return None
165
+
166
+ return process_response(res)
167
+
168
+
169
+ def ons_codes(
170
+ code_id: str = None, edition: str = None
171
+ ) -> Optional[List[Dict[str, Any]]]:
172
+ """Get codes for a specific edition of a code list.
173
+
174
+ Args:
175
+ code_id: Code list ID
176
+ edition: Edition name
177
+
178
+ Returns:
179
+ List of codes, or None if the request fails
180
+
181
+ Examples:
182
+ >>> import onspy
183
+ >>> onspy.ons_codes(code_id="quarter", edition="one-off")
184
+ """
185
+ if not assert_valid_codeid(code_id):
186
+ return None
187
+
188
+ if not assert_valid_edition(code_id, edition):
189
+ return None
190
+
191
+ req = build_base_request(
192
+ **{"code-lists": code_id, "editions": edition, "codes": EMPTY}
193
+ )
194
+ res = make_request(req)
195
+ if res is None:
196
+ return None
197
+
198
+ raw = process_response(res)
199
+ return raw.get("items", [])
200
+
201
+
202
+ def assert_valid_code(code_id: str, edition: str, code: str) -> bool:
203
+ """Check if a code is valid for an edition of a code list.
204
+
205
+ Args:
206
+ code_id: Code list ID
207
+ edition: Edition name
208
+ code: Code value
209
+
210
+ Returns:
211
+ True if valid, raises ValueError otherwise
212
+ """
213
+ if code is None:
214
+ raise ValueError("You must specify a 'code', see ons_codes()")
215
+
216
+ codes = ons_codes(code_id, edition)
217
+ if codes is None:
218
+ return False
219
+
220
+ code_values = [c.get("code", "") for c in codes]
221
+ if code not in code_values:
222
+ raise ValueError(
223
+ f"Invalid code '{code}'. Valid codes are: {', '.join(code_values)}"
224
+ )
225
+
226
+ return True
227
+
228
+
229
+ def ons_code(
230
+ code_id: str = None, edition: str = None, code: str = None
231
+ ) -> Optional[Dict[str, Any]]:
232
+ """Get details for a specific code.
233
+
234
+ Args:
235
+ code_id: Code list ID
236
+ edition: Edition name
237
+ code: Code value
238
+
239
+ Returns:
240
+ Dictionary with code details, or None if the request fails
241
+
242
+ Examples:
243
+ >>> import onspy
244
+ >>> onspy.ons_code(code_id="quarter", edition="one-off", code="q2")
245
+ """
246
+ if not assert_valid_codeid(code_id):
247
+ return None
248
+
249
+ if not assert_valid_edition(code_id, edition):
250
+ return None
251
+
252
+ if not assert_valid_code(code_id, edition, code):
253
+ return None
254
+
255
+ req = build_base_request(
256
+ **{"code-lists": code_id, "editions": edition, "codes": code}
257
+ )
258
+ res = make_request(req)
259
+ if res is None:
260
+ return None
261
+
262
+ return process_response(res)
263
+
264
+
265
+ def ons_code_dataset(
266
+ code_id: str = None, edition: str = None, code: str = None
267
+ ) -> Optional[List[Dict[str, Any]]]:
268
+ """Get datasets that use a specific code.
269
+
270
+ Args:
271
+ code_id: Code list ID
272
+ edition: Edition name
273
+ code: Code value
274
+
275
+ Returns:
276
+ List of datasets, or None if the request fails
277
+
278
+ Examples:
279
+ >>> import onspy
280
+ >>> onspy.ons_code_dataset(code_id="quarter", edition="one-off", code="q2")
281
+ """
282
+ if not assert_valid_codeid(code_id):
283
+ return None
284
+
285
+ if not assert_valid_edition(code_id, edition):
286
+ return None
287
+
288
+ if not assert_valid_code(code_id, edition, code):
289
+ return None
290
+
291
+ req = build_base_request(
292
+ **{"code-lists": code_id, "editions": edition, "codes": code, "datasets": EMPTY}
293
+ )
294
+
295
+ res = make_request(req)
296
+ if res is None:
297
+ return None
298
+
299
+ raw = process_response(res)
300
+ return raw.get("items", [])