openepd 2.0.0__py3-none-any.whl → 3.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.
- openepd/__init__.py +1 -1
- openepd/__version__.py +2 -2
- openepd/api/__init__.py +19 -0
- openepd/api/base_sync_client.py +550 -0
- openepd/api/category/__init__.py +19 -0
- openepd/api/category/dto.py +25 -0
- openepd/api/category/sync_api.py +44 -0
- openepd/api/common.py +239 -0
- openepd/api/dto/__init__.py +19 -0
- openepd/api/dto/base.py +41 -0
- openepd/api/dto/common.py +115 -0
- openepd/api/dto/meta.py +69 -0
- openepd/api/dto/mf.py +59 -0
- openepd/api/dto/params.py +19 -0
- openepd/api/epd/__init__.py +19 -0
- openepd/api/epd/dto.py +121 -0
- openepd/api/epd/sync_api.py +105 -0
- openepd/api/errors.py +86 -0
- openepd/api/pcr/__init__.py +19 -0
- openepd/api/pcr/dto.py +41 -0
- openepd/api/pcr/sync_api.py +49 -0
- openepd/api/sync_client.py +67 -0
- openepd/api/test/__init__.py +19 -0
- openepd/bundle/__init__.py +1 -1
- openepd/bundle/base.py +1 -1
- openepd/bundle/model.py +5 -6
- openepd/bundle/reader.py +5 -5
- openepd/bundle/writer.py +5 -4
- openepd/compat/__init__.py +19 -0
- openepd/compat/pydantic.py +29 -0
- openepd/model/__init__.py +1 -1
- openepd/model/base.py +114 -15
- openepd/model/category.py +39 -0
- openepd/model/common.py +33 -25
- openepd/model/epd.py +97 -78
- openepd/model/factory.py +48 -0
- openepd/model/lcia.py +24 -13
- openepd/model/org.py +28 -18
- openepd/model/pcr.py +42 -14
- openepd/model/specs/README.md +19 -0
- openepd/model/specs/__init__.py +72 -4
- openepd/model/specs/aluminium.py +67 -0
- openepd/model/specs/asphalt.py +87 -0
- openepd/model/specs/base.py +60 -0
- openepd/model/specs/concrete.py +288 -24
- openepd/model/specs/generated/accessories.py +63 -0
- openepd/model/specs/generated/aggregates.py +71 -0
- openepd/model/specs/generated/aluminium.py +66 -0
- openepd/model/specs/generated/asphalt.py +86 -0
- openepd/model/specs/generated/bulk_materials.py +26 -0
- openepd/model/specs/generated/cast_decks_and_underlayment.py +26 -0
- openepd/model/specs/generated/cladding.py +214 -0
- openepd/model/specs/generated/cmu.py +46 -0
- openepd/model/specs/generated/common.py +27 -0
- openepd/model/specs/generated/concrete.py +151 -0
- openepd/model/specs/generated/conveying_equipment.py +57 -0
- openepd/model/specs/generated/electrical.py +297 -0
- openepd/model/specs/generated/electrical_transmission_and_distribution_equipment.py +63 -0
- openepd/model/specs/generated/electricity.py +26 -0
- openepd/model/specs/generated/enums.py +2420 -0
- openepd/model/specs/generated/finishes.py +519 -0
- openepd/model/specs/generated/fire_and_smoke_protection.py +79 -0
- openepd/model/specs/generated/furnishings.py +95 -0
- openepd/model/specs/generated/grouting.py +26 -0
- openepd/model/specs/generated/manufacturing_inputs.py +131 -0
- openepd/model/specs/generated/masonry.py +77 -0
- openepd/model/specs/generated/material_handling.py +35 -0
- openepd/model/specs/generated/mechanical.py +271 -0
- openepd/model/specs/generated/mechanical_insulation.py +41 -0
- openepd/model/specs/generated/network_infrastructure.py +181 -0
- openepd/model/specs/generated/openings.py +423 -0
- openepd/model/specs/generated/other_electrical_equipment.py +26 -0
- openepd/model/specs/generated/other_materials.py +123 -0
- openepd/model/specs/generated/plumbing.py +153 -0
- openepd/model/specs/generated/precast_concrete.py +68 -0
- openepd/model/specs/generated/sheathing.py +74 -0
- openepd/model/specs/generated/steel.py +224 -0
- openepd/model/specs/generated/thermal_moisture_protection.py +233 -0
- openepd/model/specs/generated/utility_piping.py +65 -0
- openepd/model/specs/generated/wood.py +167 -0
- openepd/model/specs/generated/wood_joists.py +38 -0
- openepd/model/specs/glass.py +360 -0
- openepd/model/specs/steel.py +184 -0
- openepd/model/specs/wood.py +130 -0
- openepd/model/standard.py +2 -3
- openepd/model/validation/__init__.py +19 -0
- openepd/model/validation/common.py +59 -0
- openepd/model/validation/numbers.py +26 -0
- openepd/model/validation/quantity.py +132 -0
- openepd/model/versioning.py +129 -0
- {openepd-2.0.0.dist-info → openepd-3.1.0.dist-info}/METADATA +36 -5
- openepd-3.1.0.dist-info/RECORD +95 -0
- openepd-2.0.0.dist-info/RECORD +0 -22
- {openepd-2.0.0.dist-info → openepd-3.1.0.dist-info}/LICENSE +0 -0
- {openepd-2.0.0.dist-info → openepd-3.1.0.dist-info}/WHEEL +0 -0
openepd/__init__.py
CHANGED
openepd/__version__.py
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
#
|
2
|
-
# Copyright
|
2
|
+
# Copyright 2024 by C Change Labs Inc. www.c-change-labs.com
|
3
3
|
#
|
4
4
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
5
|
# you may not use this file except in compliance with the License.
|
@@ -17,4 +17,4 @@
|
|
17
17
|
# Charles Pankow Foundation, Microsoft Sustainability Fund, Interface, MKA Foundation, and others.
|
18
18
|
# Find out more at www.BuildingTransparency.org
|
19
19
|
#
|
20
|
-
VERSION = "
|
20
|
+
VERSION = "3.1.0"
|
openepd/api/__init__.py
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
#
|
2
|
+
# Copyright 2024 by C Change Labs Inc. www.c-change-labs.com
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
#
|
16
|
+
# This software was developed with support from the Skanska USA,
|
17
|
+
# Charles Pankow Foundation, Microsoft Sustainability Fund, Interface, MKA Foundation, and others.
|
18
|
+
# Find out more at www.BuildingTransparency.org
|
19
|
+
#
|
@@ -0,0 +1,550 @@
|
|
1
|
+
#
|
2
|
+
# Copyright 2024 by C Change Labs Inc. www.c-change-labs.com
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
#
|
16
|
+
# This software was developed with support from the Skanska USA,
|
17
|
+
# Charles Pankow Foundation, Microsoft Sustainability Fund, Interface, MKA Foundation, and others.
|
18
|
+
# Find out more at www.BuildingTransparency.org
|
19
|
+
#
|
20
|
+
__all__ = (
|
21
|
+
"HttpStreamReader",
|
22
|
+
"SyncHttpClient",
|
23
|
+
"DoRequest",
|
24
|
+
"RetryHandler",
|
25
|
+
"BaseApiMethodGroup",
|
26
|
+
"USER_AGENT_DEFAULT",
|
27
|
+
)
|
28
|
+
|
29
|
+
import datetime
|
30
|
+
from functools import partial, wraps
|
31
|
+
from io import IOBase
|
32
|
+
import logging
|
33
|
+
import random
|
34
|
+
import shutil
|
35
|
+
import time
|
36
|
+
from typing import IO, Any, BinaryIO, Callable, Final, NamedTuple
|
37
|
+
|
38
|
+
import requests
|
39
|
+
from requests import PreparedRequest, Response, Session, Timeout
|
40
|
+
from requests import codes as requests_codes
|
41
|
+
from requests.auth import AuthBase
|
42
|
+
from requests.structures import CaseInsensitiveDict
|
43
|
+
|
44
|
+
from openepd.__version__ import VERSION
|
45
|
+
from openepd.api import errors
|
46
|
+
from openepd.api.common import Throttler, no_trailing_slash
|
47
|
+
|
48
|
+
logger = logging.getLogger(__name__)
|
49
|
+
|
50
|
+
USER_AGENT_DEFAULT: Final[str] = f"OpenEPD API Client/{VERSION}"
|
51
|
+
|
52
|
+
DoRequest = Callable[[], Response]
|
53
|
+
RetryHandler = Callable[[DoRequest], Response | None]
|
54
|
+
ErrorHandler = Callable[[Response, bool], Response | None]
|
55
|
+
"""
|
56
|
+
ErrorHandler is a callable that takes a response and a boolean indicating whether
|
57
|
+
exception should be raised in case of error.
|
58
|
+
"""
|
59
|
+
|
60
|
+
|
61
|
+
class HttpStreamReader(IOBase):
|
62
|
+
"""A wrapper around a requests Response object that allows it to be used as a stream."""
|
63
|
+
|
64
|
+
def __init__(self, http_response: Response, chunk_size: int = 1024) -> None:
|
65
|
+
"""
|
66
|
+
Initialize HttpStreamReader with given HttpResponse in streaming mode.
|
67
|
+
|
68
|
+
:param http_response: HTTP response object
|
69
|
+
"""
|
70
|
+
super().__init__()
|
71
|
+
self._http_response = http_response
|
72
|
+
self.chunk_size = chunk_size
|
73
|
+
|
74
|
+
def readable(self) -> bool:
|
75
|
+
"""Return True if the stream can be read from."""
|
76
|
+
return True
|
77
|
+
|
78
|
+
def seekable(self) -> bool:
|
79
|
+
"""Return True if the stream supports random access."""
|
80
|
+
return False
|
81
|
+
|
82
|
+
def writable(self) -> bool:
|
83
|
+
"""Return True if the stream supports writing."""
|
84
|
+
return False
|
85
|
+
|
86
|
+
def read(self, size: int = -1) -> bytes:
|
87
|
+
"""Read and return up to size bytes, where size is an int."""
|
88
|
+
return self._http_response.raw.read(size)
|
89
|
+
|
90
|
+
def readinto(self, target_stream: IO[bytes]) -> None:
|
91
|
+
"""Read bytes into a pre-allocated, writable bytes-like object target_stream."""
|
92
|
+
shutil.copyfileobj(self._http_response.raw, target_stream)
|
93
|
+
|
94
|
+
def raw(self) -> BinaryIO:
|
95
|
+
"""Return the underlying HTTP Response content."""
|
96
|
+
return self._http_response.raw
|
97
|
+
|
98
|
+
def detach(self) -> BinaryIO:
|
99
|
+
"""Separate the underlying HTTP Response from the HttpStreamReader and return it."""
|
100
|
+
tmp = self._http_response
|
101
|
+
self._http_response = None # type: ignore
|
102
|
+
return tmp.raw
|
103
|
+
|
104
|
+
def close(self) -> None:
|
105
|
+
"""
|
106
|
+
Releases the connection back to the pool.
|
107
|
+
|
108
|
+
Once this method has been called the underlying ``raw`` object must not be accessed again.
|
109
|
+
*Note: Should not normally need to be called explicitly.*
|
110
|
+
"""
|
111
|
+
self._http_response.close()
|
112
|
+
|
113
|
+
def get_size(self) -> int:
|
114
|
+
"""Return the size of the response in bytes."""
|
115
|
+
return int(self._http_response.headers.get("Content-Length", 0))
|
116
|
+
|
117
|
+
def get_content_type(self) -> str:
|
118
|
+
"""Return the content type of the response."""
|
119
|
+
return self._http_response.headers.get("Content-Type", "")
|
120
|
+
|
121
|
+
def get_status_code(self) -> int:
|
122
|
+
"""Return the status code of the response."""
|
123
|
+
return self._http_response.status_code
|
124
|
+
|
125
|
+
def get_headers(self) -> CaseInsensitiveDict[str]:
|
126
|
+
"""Return the headers of the response."""
|
127
|
+
return self._http_response.headers
|
128
|
+
|
129
|
+
|
130
|
+
class TokenAuth(AuthBase):
|
131
|
+
"""Attach OpenEPD Token Authentication to the given Request object."""
|
132
|
+
|
133
|
+
def __init__(self, token: str) -> None:
|
134
|
+
super().__init__()
|
135
|
+
self.token = token
|
136
|
+
|
137
|
+
def __eq__(self, other):
|
138
|
+
if other is None:
|
139
|
+
return False
|
140
|
+
if other.__class__ is not self.__class__:
|
141
|
+
return False
|
142
|
+
return self.token == other.token
|
143
|
+
|
144
|
+
def __call__(self, r: PreparedRequest) -> PreparedRequest:
|
145
|
+
r.headers["Authorization"] = f"Bearer {self.token}"
|
146
|
+
return r
|
147
|
+
|
148
|
+
|
149
|
+
class SyncHttpClient:
|
150
|
+
"""
|
151
|
+
HTTP client to communicate with OpenEPD servers via HTTP.
|
152
|
+
|
153
|
+
It works on top of requests library and provides some commonly needed features, such as throttling, retries, etc.
|
154
|
+
"""
|
155
|
+
|
156
|
+
HTTP_DATE_TIME_FORMAT = "%a, %d %b %Y %H:%M:%S %Z"
|
157
|
+
DEFAULT_RETRY_INTERVAL_SEC = 10
|
158
|
+
|
159
|
+
def __init__(
|
160
|
+
self,
|
161
|
+
base_url: str,
|
162
|
+
throttle_retry_timeout: float | int | datetime.timedelta = 300,
|
163
|
+
requests_per_sec: float = 10,
|
164
|
+
retry_count: int = 3,
|
165
|
+
user_agent: str | None = None,
|
166
|
+
timeout_sec: float | tuple[float, float] | None = None,
|
167
|
+
auth: AuthBase | None = None,
|
168
|
+
):
|
169
|
+
"""
|
170
|
+
Construct BaseApiClient.
|
171
|
+
|
172
|
+
:param base_url: common part that all request URLs start with
|
173
|
+
:param throttle_retry_timeout: how long to wait before retrying throttled request.
|
174
|
+
Either number of seconds or timedelta
|
175
|
+
:param requests_per_sec: requests per second
|
176
|
+
:param user_agent: user agent to pass along with a request,
|
177
|
+
if `None` then underlying library decides which one to pass
|
178
|
+
:param timeout_sec: how long to wait for the server to send data before giving up,
|
179
|
+
as a seconds (just a single float), or a (connect timeout, read timeout) tuple.
|
180
|
+
:param retry_count: count of retries to perform in case of connection error or timeout.
|
181
|
+
"""
|
182
|
+
self._base_url: str = no_trailing_slash(base_url)
|
183
|
+
self._throttler = Throttler(rate_per_sec=requests_per_sec)
|
184
|
+
self._throttle_retry_timeout: float = (
|
185
|
+
float(throttle_retry_timeout)
|
186
|
+
if isinstance(throttle_retry_timeout, (float, int))
|
187
|
+
else throttle_retry_timeout.total_seconds()
|
188
|
+
)
|
189
|
+
self.user_agent = user_agent
|
190
|
+
self.timeout = timeout_sec
|
191
|
+
self._session: Session | None = None
|
192
|
+
self._auth: AuthBase | None = auth
|
193
|
+
self._retry_count: int = retry_count
|
194
|
+
|
195
|
+
self._http_retry_handlers: dict[int, RetryHandler] = {}
|
196
|
+
self._http_error_handlers: dict[int, ErrorHandler] = {}
|
197
|
+
|
198
|
+
self.register_error_handler(400, DefaultOpenApiErrorHandlers.handle_bad_request)
|
199
|
+
self.register_error_handler(401, DefaultOpenApiErrorHandlers.handle_unauthorized)
|
200
|
+
self.register_error_handler(403, DefaultOpenApiErrorHandlers.handle_access_denied)
|
201
|
+
self.register_error_handler(404, DefaultOpenApiErrorHandlers.handle_not_found)
|
202
|
+
self.register_error_handler(500, DefaultOpenApiErrorHandlers.handle_server_error)
|
203
|
+
|
204
|
+
@property
|
205
|
+
def base_url(self) -> str:
|
206
|
+
"""Return base URL for all requests."""
|
207
|
+
return self._base_url
|
208
|
+
|
209
|
+
@base_url.setter
|
210
|
+
def base_url(self, new_value: str):
|
211
|
+
"""Set base URL for all requests."""
|
212
|
+
self._base_url = no_trailing_slash(new_value)
|
213
|
+
|
214
|
+
@property
|
215
|
+
def default_headers(self) -> dict[str, str]:
|
216
|
+
"""Default headers for requests. Implement if required."""
|
217
|
+
headers = {}
|
218
|
+
if self.user_agent:
|
219
|
+
headers["user-agent"] = self.user_agent
|
220
|
+
return headers
|
221
|
+
|
222
|
+
def reset_session(self) -> None:
|
223
|
+
"""Reset current session (if any). This will clear all cookies and other session data."""
|
224
|
+
self._session = None
|
225
|
+
|
226
|
+
def read_bytes_from_url(self, url: str, method: str = "get", **kwargs) -> bytes:
|
227
|
+
"""
|
228
|
+
Perform query to the given endpoint and returns response body as bytes.
|
229
|
+
|
230
|
+
This method will load the ENTIRE CONTENT IN MEMORY. DO NOT USE it to download files. Consider using
|
231
|
+
`write_to_stream` instead.
|
232
|
+
The request will be performed in the context of API client, default error handling will be applied.
|
233
|
+
|
234
|
+
:param url: url pointing the target endpoint
|
235
|
+
:param method: optional HTTP method
|
236
|
+
:param kwargs: any other arguments supported by _do_request. See `BaseApiClient._do_request`.
|
237
|
+
:return: response content as bytes
|
238
|
+
"""
|
239
|
+
r = self.do_request(method, url, **kwargs)
|
240
|
+
content = r.content
|
241
|
+
return content
|
242
|
+
|
243
|
+
def read_url_write_to_stream(
|
244
|
+
self, url: str, target_stream: IO[bytes], method: str = "get", chunk_size: int = 1024, **kwargs
|
245
|
+
) -> int:
|
246
|
+
"""
|
247
|
+
Perform query to the given endpoint and writes response body to the given stream.
|
248
|
+
|
249
|
+
:param url: url pointing the target endpoint
|
250
|
+
:param target_stream: stream to write response body to
|
251
|
+
:param method: optional HTTP method
|
252
|
+
:param chunk_size: size of the chunk to read from the response
|
253
|
+
:param kwargs: any other arguments supported by _do_request. See `BaseApiClient._do_request`.
|
254
|
+
:return: number of bytes written
|
255
|
+
"""
|
256
|
+
with self.do_request(method, url, stream=True, **kwargs) as r:
|
257
|
+
size = 0
|
258
|
+
for chunk in r.iter_content(chunk_size=chunk_size):
|
259
|
+
target_stream.write(chunk)
|
260
|
+
size += len(chunk)
|
261
|
+
return size
|
262
|
+
|
263
|
+
def read_stream_from_url(self, url: str, method: str = "get", **kwargs) -> HttpStreamReader | IO[bytes]:
|
264
|
+
"""
|
265
|
+
Perform query to the given endpoint and returns response body as a stream.
|
266
|
+
|
267
|
+
The request will be performed in the context of API client, default error handling will be applied.
|
268
|
+
NOTE: Consider using it as a context manager to handle stream close correctly.
|
269
|
+
|
270
|
+
:param url: url pointing the target endpoint
|
271
|
+
:param method: optional HTTP method
|
272
|
+
:param kwargs: any other arguments supported by _do_request. See `BaseApiClient._do_request`.
|
273
|
+
:return: response content as a stream
|
274
|
+
"""
|
275
|
+
r = self.do_request(method, url, stream=True, **kwargs)
|
276
|
+
return HttpStreamReader(r)
|
277
|
+
|
278
|
+
def register_retry_handler(self, http_error: int, retry_handler: RetryHandler | None) -> None:
|
279
|
+
"""
|
280
|
+
Register retry handler for the given HTTP error code.
|
281
|
+
|
282
|
+
This allows to override default error handling for the given HTTP error code from the subclass.
|
283
|
+
"""
|
284
|
+
if retry_handler is not None:
|
285
|
+
self._http_retry_handlers[http_error] = retry_handler
|
286
|
+
|
287
|
+
def delete_retry_handler(self, http_error: int) -> None:
|
288
|
+
"""
|
289
|
+
Delete retry handler for the given HTTP error code.
|
290
|
+
|
291
|
+
See _register_error_handler for more details.
|
292
|
+
"""
|
293
|
+
if http_error in self._http_retry_handlers:
|
294
|
+
del self._http_retry_handlers[http_error]
|
295
|
+
|
296
|
+
def register_error_handler(self, http_error: int, error_handler: ErrorHandler | None) -> None:
|
297
|
+
"""
|
298
|
+
Register retry handler for the given HTTP error code.
|
299
|
+
|
300
|
+
This allows to override default error handling for the given HTTP error code from the subclass.
|
301
|
+
"""
|
302
|
+
if error_handler is not None:
|
303
|
+
self._http_error_handlers[http_error] = error_handler
|
304
|
+
|
305
|
+
def _delete_error_handler(self, http_error: int) -> None:
|
306
|
+
"""
|
307
|
+
Delete retry handler for the given HTTP error code.
|
308
|
+
|
309
|
+
See _register_error_handler for more details.
|
310
|
+
"""
|
311
|
+
if http_error in self._http_error_handlers:
|
312
|
+
del self._http_error_handlers[http_error]
|
313
|
+
|
314
|
+
def _run_throttled_request(
|
315
|
+
self,
|
316
|
+
method: str,
|
317
|
+
url: str,
|
318
|
+
request_kwargs: dict[str, Any],
|
319
|
+
session: Session | None = None,
|
320
|
+
) -> Response:
|
321
|
+
left_time = self._throttle_retry_timeout
|
322
|
+
# override current session to do request to some other server from the same API client
|
323
|
+
session = session or self._current_session
|
324
|
+
while True:
|
325
|
+
with self._throttler.throttle():
|
326
|
+
resp = session.request(method, url, **request_kwargs)
|
327
|
+
if resp.status_code == requests_codes.too_many_requests:
|
328
|
+
timeout = self._get_timeout_from_retry_after_header(
|
329
|
+
resp.headers.get("Retry-After"), self.DEFAULT_RETRY_INTERVAL_SEC
|
330
|
+
)
|
331
|
+
if timeout > left_time:
|
332
|
+
return resp
|
333
|
+
logger.info("`%s %s` has been throttled for %s second(s)", method, url, timeout)
|
334
|
+
time.sleep(timeout)
|
335
|
+
left_time -= timeout
|
336
|
+
if left_time > 0:
|
337
|
+
continue
|
338
|
+
return resp
|
339
|
+
|
340
|
+
def do_request(
|
341
|
+
self,
|
342
|
+
method: str,
|
343
|
+
endpoint: str,
|
344
|
+
params=None,
|
345
|
+
data=None,
|
346
|
+
json=None,
|
347
|
+
files=None,
|
348
|
+
headers=None,
|
349
|
+
session: Session | None = None,
|
350
|
+
auth: AuthBase | None = None,
|
351
|
+
raise_for_status: bool = True,
|
352
|
+
**kwargs,
|
353
|
+
) -> Response:
|
354
|
+
"""
|
355
|
+
Perform request to the given endpoint.
|
356
|
+
|
357
|
+
See https://requests.readthedocs.io/en/master/api/#requests.request for more details on kwargs.
|
358
|
+
"""
|
359
|
+
headers = headers or self.default_headers
|
360
|
+
|
361
|
+
self._on_before_do_request()
|
362
|
+
|
363
|
+
url = self._get_url_for_request(endpoint)
|
364
|
+
|
365
|
+
request_kwargs = dict(
|
366
|
+
params=params,
|
367
|
+
data=data,
|
368
|
+
json=json,
|
369
|
+
files=files,
|
370
|
+
headers=headers,
|
371
|
+
timeout=self.timeout,
|
372
|
+
auth=auth or self._auth,
|
373
|
+
)
|
374
|
+
request_kwargs.update(kwargs)
|
375
|
+
|
376
|
+
do_request = self._handle_service_unavailable(
|
377
|
+
method,
|
378
|
+
url,
|
379
|
+
self._retry_count,
|
380
|
+
partial(self._run_throttled_request, method, url, request_kwargs, session=session),
|
381
|
+
)
|
382
|
+
|
383
|
+
response = do_request()
|
384
|
+
|
385
|
+
if response.ok:
|
386
|
+
return response
|
387
|
+
|
388
|
+
retry_handler = self._http_retry_handlers.get(response.status_code, None)
|
389
|
+
if retry_handler:
|
390
|
+
result = retry_handler(do_request)
|
391
|
+
response = result or response
|
392
|
+
|
393
|
+
error_handler = self._http_error_handlers.get(response.status_code, None)
|
394
|
+
if error_handler:
|
395
|
+
response = error_handler(response, raise_for_status)
|
396
|
+
|
397
|
+
if response.ok or not raise_for_status:
|
398
|
+
return response
|
399
|
+
|
400
|
+
response.raise_for_status()
|
401
|
+
# This can't be handled by static checker because of the dynamic nature of the raise_for_status method
|
402
|
+
raise RuntimeError("This line should never be reached")
|
403
|
+
|
404
|
+
def _get_url_for_request(self, path_or_url: str) -> str:
|
405
|
+
"""
|
406
|
+
Generate url for given input.
|
407
|
+
|
408
|
+
If absolute path is given it will be returned as is, otherwise the base url will be prepended.
|
409
|
+
:param path_or_url: Either absolute url or base path.
|
410
|
+
:return: absolute url
|
411
|
+
"""
|
412
|
+
return self._base_url + path_or_url if not path_or_url.startswith("http") else path_or_url
|
413
|
+
|
414
|
+
@property
|
415
|
+
def _current_session(self) -> Session:
|
416
|
+
if self._session is None:
|
417
|
+
self._session = Session()
|
418
|
+
return self._session
|
419
|
+
|
420
|
+
def _on_before_do_request(self):
|
421
|
+
"""
|
422
|
+
Perform any actions before request is sent.
|
423
|
+
|
424
|
+
This is a hook that will be called before `do_request`. Can be overridden to check / refresh access tokens.
|
425
|
+
"""
|
426
|
+
pass
|
427
|
+
|
428
|
+
def _get_timeout_from_retry_after_header(self, retry_after: str | None, default: float = 10.0) -> float:
|
429
|
+
if retry_after is None:
|
430
|
+
return default
|
431
|
+
try:
|
432
|
+
return float(retry_after)
|
433
|
+
except ValueError:
|
434
|
+
# This means the value is not at number of seconds but a date, so we parse it
|
435
|
+
try:
|
436
|
+
date_in_future = datetime.datetime.strptime(retry_after.strip(), self.HTTP_DATE_TIME_FORMAT)
|
437
|
+
return (date_in_future - datetime.datetime.utcnow()).total_seconds()
|
438
|
+
except ValueError:
|
439
|
+
logger.warning("Invalid Retry-After header: %s", retry_after)
|
440
|
+
return default
|
441
|
+
|
442
|
+
@staticmethod
|
443
|
+
def _handle_service_unavailable(method: str, url: str, retry_count: int, func: Callable):
|
444
|
+
@wraps(func)
|
445
|
+
def wrapper(*args, **kwargs):
|
446
|
+
attempts = retry_count
|
447
|
+
response = None
|
448
|
+
exception = None
|
449
|
+
while attempts > 0:
|
450
|
+
exception = None
|
451
|
+
try:
|
452
|
+
response = func(*args, **kwargs)
|
453
|
+
except (requests.exceptions.ConnectionError, ConnectionError, Timeout) as e:
|
454
|
+
exception = e
|
455
|
+
|
456
|
+
if exception or response.status_code == requests_codes.service_unavailable:
|
457
|
+
secs = random.randint(60, 60 * 5)
|
458
|
+
logger.warning(
|
459
|
+
"%s %s is unavailable. Attempts left: %s. Waiting %s seconds...", method, url, attempts, secs
|
460
|
+
)
|
461
|
+
|
462
|
+
# wait random number of seconds and request again
|
463
|
+
time.sleep(secs)
|
464
|
+
attempts -= 1
|
465
|
+
else:
|
466
|
+
break
|
467
|
+
if exception:
|
468
|
+
raise exception
|
469
|
+
return response
|
470
|
+
|
471
|
+
return wrapper
|
472
|
+
|
473
|
+
|
474
|
+
class DefaultOpenApiErrorHandlers:
|
475
|
+
class _Error(NamedTuple):
|
476
|
+
summary: str
|
477
|
+
error_code: str | None
|
478
|
+
details: dict[str, list[str]] | None
|
479
|
+
|
480
|
+
@staticmethod
|
481
|
+
def _parse_error_response(response: Response) -> _Error:
|
482
|
+
error_text = response.text
|
483
|
+
validation_errors: dict[str, Any] | None = None
|
484
|
+
error_code: str | None = None
|
485
|
+
if response.headers.get("content-type") == "application/json":
|
486
|
+
json_error = response.json()
|
487
|
+
error_text = json_error.get("detail", error_text)
|
488
|
+
validation_errors = json_error.get("validation_errors", None)
|
489
|
+
if validation_errors is not None:
|
490
|
+
error_code = validation_errors.get("code", None)
|
491
|
+
if isinstance(validation_errors.get("detail", 0), str):
|
492
|
+
error_text = validation_errors.get("detail", "")
|
493
|
+
validation_errors = dict(msg=validation_errors.get("detail", []))
|
494
|
+
if error_code is not None:
|
495
|
+
error_text = f"[{error_code}] {error_text}"
|
496
|
+
return DefaultOpenApiErrorHandlers._Error(error_text, error_code, validation_errors)
|
497
|
+
|
498
|
+
@staticmethod
|
499
|
+
def handle_bad_request(response: Response, raise_for_status: bool) -> Response | None:
|
500
|
+
if raise_for_status:
|
501
|
+
error = DefaultOpenApiErrorHandlers._parse_error_response(response)
|
502
|
+
raise errors.ValidationError(
|
503
|
+
response.status_code,
|
504
|
+
error.summary,
|
505
|
+
validation_errors=error.details,
|
506
|
+
response=response,
|
507
|
+
error_code=error.error_code,
|
508
|
+
)
|
509
|
+
return response
|
510
|
+
|
511
|
+
@staticmethod
|
512
|
+
def handle_not_found(response: Response, raise_for_status: bool) -> Response | None:
|
513
|
+
if raise_for_status:
|
514
|
+
error = DefaultOpenApiErrorHandlers._parse_error_response(response)
|
515
|
+
raise errors.ObjectNotFound(response.status_code, error.summary, response, error_code=error.error_code)
|
516
|
+
return response
|
517
|
+
|
518
|
+
@staticmethod
|
519
|
+
def handle_unauthorized(response: Response, raise_for_status: bool) -> Response | None:
|
520
|
+
if raise_for_status:
|
521
|
+
error = DefaultOpenApiErrorHandlers._parse_error_response(response)
|
522
|
+
raise errors.NotAuthorizedError(response.status_code, error.summary, response, error_code=error.error_code)
|
523
|
+
return response
|
524
|
+
|
525
|
+
@staticmethod
|
526
|
+
def handle_access_denied(response: Response, raise_for_status: bool) -> Response | None:
|
527
|
+
if raise_for_status:
|
528
|
+
error = DefaultOpenApiErrorHandlers._parse_error_response(response)
|
529
|
+
raise errors.AccessDeniedError(response.status_code, error.summary, response, error_code=error.error_code)
|
530
|
+
return response
|
531
|
+
|
532
|
+
@staticmethod
|
533
|
+
def handle_server_error(response: Response, raise_for_status: bool) -> Response | None:
|
534
|
+
if raise_for_status:
|
535
|
+
error = DefaultOpenApiErrorHandlers._parse_error_response(response)
|
536
|
+
raise errors.ServerError(response.status_code, error.summary, response, error_code=error.error_code)
|
537
|
+
return response
|
538
|
+
|
539
|
+
|
540
|
+
class BaseApiMethodGroup:
|
541
|
+
"""Base class for API method groups."""
|
542
|
+
|
543
|
+
def __init__(self, client: SyncHttpClient) -> None:
|
544
|
+
"""
|
545
|
+
Construct a method group.
|
546
|
+
|
547
|
+
:param client: HTTP client to use for requests
|
548
|
+
"""
|
549
|
+
super().__init__()
|
550
|
+
self._client = client
|
@@ -0,0 +1,19 @@
|
|
1
|
+
#
|
2
|
+
# Copyright 2024 by C Change Labs Inc. www.c-change-labs.com
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
#
|
16
|
+
# This software was developed with support from the Skanska USA,
|
17
|
+
# Charles Pankow Foundation, Microsoft Sustainability Fund, Interface, MKA Foundation, and others.
|
18
|
+
# Find out more at www.BuildingTransparency.org
|
19
|
+
#
|
@@ -0,0 +1,25 @@
|
|
1
|
+
#
|
2
|
+
# Copyright 2024 by C Change Labs Inc. www.c-change-labs.com
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
#
|
16
|
+
# This software was developed with support from the Skanska USA,
|
17
|
+
# Charles Pankow Foundation, Microsoft Sustainability Fund, Interface, MKA Foundation, and others.
|
18
|
+
# Find out more at www.BuildingTransparency.org
|
19
|
+
#
|
20
|
+
from typing import TypeAlias
|
21
|
+
|
22
|
+
from openepd.api.dto.common import MetaCollectionDto, OpenEpdApiResponse
|
23
|
+
from openepd.model.category import Category
|
24
|
+
|
25
|
+
CategoryTreeResponse: TypeAlias = OpenEpdApiResponse[Category, MetaCollectionDto]
|
@@ -0,0 +1,44 @@
|
|
1
|
+
#
|
2
|
+
# Copyright 2024 by C Change Labs Inc. www.c-change-labs.com
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
#
|
16
|
+
# This software was developed with support from the Skanska USA,
|
17
|
+
# Charles Pankow Foundation, Microsoft Sustainability Fund, Interface, MKA Foundation, and others.
|
18
|
+
# Find out more at www.BuildingTransparency.org
|
19
|
+
#
|
20
|
+
from openepd.api.base_sync_client import BaseApiMethodGroup
|
21
|
+
from openepd.api.category.dto import CategoryTreeResponse
|
22
|
+
from openepd.model.category import Category
|
23
|
+
|
24
|
+
|
25
|
+
class CategoryApi(BaseApiMethodGroup):
|
26
|
+
"""API methods for reading categories."""
|
27
|
+
|
28
|
+
def get_tree_raw(self) -> CategoryTreeResponse:
|
29
|
+
"""
|
30
|
+
Get categories tree.
|
31
|
+
|
32
|
+
:return: categories tree wrapped in OpenEpdApiResponse
|
33
|
+
"""
|
34
|
+
response = self._client.do_request("get", "/v2/categories/tree")
|
35
|
+
return CategoryTreeResponse.parse_raw(response.content)
|
36
|
+
|
37
|
+
def get_tree(self) -> Category:
|
38
|
+
"""
|
39
|
+
Get categories tree.
|
40
|
+
|
41
|
+
:return: categories tree
|
42
|
+
"""
|
43
|
+
response = self.get_tree_raw()
|
44
|
+
return response.payload
|