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.
Files changed (95) hide show
  1. openepd/__init__.py +1 -1
  2. openepd/__version__.py +2 -2
  3. openepd/api/__init__.py +19 -0
  4. openepd/api/base_sync_client.py +550 -0
  5. openepd/api/category/__init__.py +19 -0
  6. openepd/api/category/dto.py +25 -0
  7. openepd/api/category/sync_api.py +44 -0
  8. openepd/api/common.py +239 -0
  9. openepd/api/dto/__init__.py +19 -0
  10. openepd/api/dto/base.py +41 -0
  11. openepd/api/dto/common.py +115 -0
  12. openepd/api/dto/meta.py +69 -0
  13. openepd/api/dto/mf.py +59 -0
  14. openepd/api/dto/params.py +19 -0
  15. openepd/api/epd/__init__.py +19 -0
  16. openepd/api/epd/dto.py +121 -0
  17. openepd/api/epd/sync_api.py +105 -0
  18. openepd/api/errors.py +86 -0
  19. openepd/api/pcr/__init__.py +19 -0
  20. openepd/api/pcr/dto.py +41 -0
  21. openepd/api/pcr/sync_api.py +49 -0
  22. openepd/api/sync_client.py +67 -0
  23. openepd/api/test/__init__.py +19 -0
  24. openepd/bundle/__init__.py +1 -1
  25. openepd/bundle/base.py +1 -1
  26. openepd/bundle/model.py +5 -6
  27. openepd/bundle/reader.py +5 -5
  28. openepd/bundle/writer.py +5 -4
  29. openepd/compat/__init__.py +19 -0
  30. openepd/compat/pydantic.py +29 -0
  31. openepd/model/__init__.py +1 -1
  32. openepd/model/base.py +114 -15
  33. openepd/model/category.py +39 -0
  34. openepd/model/common.py +33 -25
  35. openepd/model/epd.py +97 -78
  36. openepd/model/factory.py +48 -0
  37. openepd/model/lcia.py +24 -13
  38. openepd/model/org.py +28 -18
  39. openepd/model/pcr.py +42 -14
  40. openepd/model/specs/README.md +19 -0
  41. openepd/model/specs/__init__.py +72 -4
  42. openepd/model/specs/aluminium.py +67 -0
  43. openepd/model/specs/asphalt.py +87 -0
  44. openepd/model/specs/base.py +60 -0
  45. openepd/model/specs/concrete.py +288 -24
  46. openepd/model/specs/generated/accessories.py +63 -0
  47. openepd/model/specs/generated/aggregates.py +71 -0
  48. openepd/model/specs/generated/aluminium.py +66 -0
  49. openepd/model/specs/generated/asphalt.py +86 -0
  50. openepd/model/specs/generated/bulk_materials.py +26 -0
  51. openepd/model/specs/generated/cast_decks_and_underlayment.py +26 -0
  52. openepd/model/specs/generated/cladding.py +214 -0
  53. openepd/model/specs/generated/cmu.py +46 -0
  54. openepd/model/specs/generated/common.py +27 -0
  55. openepd/model/specs/generated/concrete.py +151 -0
  56. openepd/model/specs/generated/conveying_equipment.py +57 -0
  57. openepd/model/specs/generated/electrical.py +297 -0
  58. openepd/model/specs/generated/electrical_transmission_and_distribution_equipment.py +63 -0
  59. openepd/model/specs/generated/electricity.py +26 -0
  60. openepd/model/specs/generated/enums.py +2420 -0
  61. openepd/model/specs/generated/finishes.py +519 -0
  62. openepd/model/specs/generated/fire_and_smoke_protection.py +79 -0
  63. openepd/model/specs/generated/furnishings.py +95 -0
  64. openepd/model/specs/generated/grouting.py +26 -0
  65. openepd/model/specs/generated/manufacturing_inputs.py +131 -0
  66. openepd/model/specs/generated/masonry.py +77 -0
  67. openepd/model/specs/generated/material_handling.py +35 -0
  68. openepd/model/specs/generated/mechanical.py +271 -0
  69. openepd/model/specs/generated/mechanical_insulation.py +41 -0
  70. openepd/model/specs/generated/network_infrastructure.py +181 -0
  71. openepd/model/specs/generated/openings.py +423 -0
  72. openepd/model/specs/generated/other_electrical_equipment.py +26 -0
  73. openepd/model/specs/generated/other_materials.py +123 -0
  74. openepd/model/specs/generated/plumbing.py +153 -0
  75. openepd/model/specs/generated/precast_concrete.py +68 -0
  76. openepd/model/specs/generated/sheathing.py +74 -0
  77. openepd/model/specs/generated/steel.py +224 -0
  78. openepd/model/specs/generated/thermal_moisture_protection.py +233 -0
  79. openepd/model/specs/generated/utility_piping.py +65 -0
  80. openepd/model/specs/generated/wood.py +167 -0
  81. openepd/model/specs/generated/wood_joists.py +38 -0
  82. openepd/model/specs/glass.py +360 -0
  83. openepd/model/specs/steel.py +184 -0
  84. openepd/model/specs/wood.py +130 -0
  85. openepd/model/standard.py +2 -3
  86. openepd/model/validation/__init__.py +19 -0
  87. openepd/model/validation/common.py +59 -0
  88. openepd/model/validation/numbers.py +26 -0
  89. openepd/model/validation/quantity.py +132 -0
  90. openepd/model/versioning.py +129 -0
  91. {openepd-2.0.0.dist-info → openepd-3.1.0.dist-info}/METADATA +36 -5
  92. openepd-3.1.0.dist-info/RECORD +95 -0
  93. openepd-2.0.0.dist-info/RECORD +0 -22
  94. {openepd-2.0.0.dist-info → openepd-3.1.0.dist-info}/LICENSE +0 -0
  95. {openepd-2.0.0.dist-info → openepd-3.1.0.dist-info}/WHEEL +0 -0
openepd/__init__.py CHANGED
@@ -1,5 +1,5 @@
1
1
  #
2
- # Copyright 2023 by C Change Labs Inc. www.c-change-labs.com
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.
openepd/__version__.py CHANGED
@@ -1,5 +1,5 @@
1
1
  #
2
- # Copyright 2023 by C Change Labs Inc. www.c-change-labs.com
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 = "2.0.0"
20
+ VERSION = "3.1.0"
@@ -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