pyprocore 1.0.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.
- app.py +176 -0
- auth/__init__.py +45 -0
- auth/oauth.py +166 -0
- auth/token_manager.py +106 -0
- auth/token_store.py +158 -0
- core/__init__.py +57 -0
- core/client.py +425 -0
- core/config.py +97 -0
- core/endpoints.py +54 -0
- core/exceptions.py +58 -0
- core/logger.py +141 -0
- models/__init__.py +25 -0
- models/base.py +11 -0
- models/resources.py +86 -0
- parser/__init__.py +17 -0
- parser/email_parser.py +165 -0
- pyprocore-1.0.0.dist-info/METADATA +241 -0
- pyprocore-1.0.0.dist-info/RECORD +27 -0
- pyprocore-1.0.0.dist-info/WHEEL +5 -0
- pyprocore-1.0.0.dist-info/entry_points.txt +2 -0
- pyprocore-1.0.0.dist-info/top_level.txt +6 -0
- services/__init__.py +31 -0
- services/companies.py +31 -0
- services/files.py +281 -0
- services/projects.py +87 -0
- services/rfis.py +147 -0
- services/submittals.py +140 -0
core/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Core SDK primitives for Procore API access."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AuthenticationError",
|
|
9
|
+
"AuthorizationError",
|
|
10
|
+
"ConfigurationError",
|
|
11
|
+
"ProcoreAPIError",
|
|
12
|
+
"ProcoreClient",
|
|
13
|
+
"ProcoreError",
|
|
14
|
+
"ProcoreSettings",
|
|
15
|
+
"RateLimitError",
|
|
16
|
+
"ResourceNotFoundError",
|
|
17
|
+
"TransientAPIError",
|
|
18
|
+
"ValidationError",
|
|
19
|
+
"get_logger",
|
|
20
|
+
"get_settings",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def __getattr__(name: str) -> Any:
|
|
25
|
+
"""Lazily expose core objects without creating import cycles."""
|
|
26
|
+
if name == "ProcoreClient":
|
|
27
|
+
from core.client import ProcoreClient
|
|
28
|
+
|
|
29
|
+
return ProcoreClient
|
|
30
|
+
|
|
31
|
+
if name in {"ProcoreSettings", "get_settings"}:
|
|
32
|
+
from core.config import ProcoreSettings, get_settings
|
|
33
|
+
|
|
34
|
+
return {"ProcoreSettings": ProcoreSettings, "get_settings": get_settings}[name]
|
|
35
|
+
|
|
36
|
+
if name == "get_logger":
|
|
37
|
+
from core.logger import get_logger
|
|
38
|
+
|
|
39
|
+
return get_logger
|
|
40
|
+
|
|
41
|
+
exception_names = {
|
|
42
|
+
"AuthenticationError",
|
|
43
|
+
"AuthorizationError",
|
|
44
|
+
"ConfigurationError",
|
|
45
|
+
"ProcoreAPIError",
|
|
46
|
+
"ProcoreError",
|
|
47
|
+
"RateLimitError",
|
|
48
|
+
"ResourceNotFoundError",
|
|
49
|
+
"TransientAPIError",
|
|
50
|
+
"ValidationError",
|
|
51
|
+
}
|
|
52
|
+
if name in exception_names:
|
|
53
|
+
from core import exceptions
|
|
54
|
+
|
|
55
|
+
return getattr(exceptions, name)
|
|
56
|
+
|
|
57
|
+
raise AttributeError(f"module 'core' has no attribute {name!r}")
|
core/client.py
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"""Reusable HTTP client for the Procore REST API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from time import perf_counter
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import parse_qs, urljoin, urlparse
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
from tenacity import (
|
|
12
|
+
retry,
|
|
13
|
+
retry_if_exception_type,
|
|
14
|
+
stop_after_attempt,
|
|
15
|
+
wait_exponential,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from auth.token_manager import TokenManager
|
|
19
|
+
from core.config import ProcoreSettings, get_settings
|
|
20
|
+
from core.exceptions import (
|
|
21
|
+
AuthenticationError,
|
|
22
|
+
AuthorizationError,
|
|
23
|
+
ProcoreAPIError,
|
|
24
|
+
RateLimitError,
|
|
25
|
+
ResourceNotFoundError,
|
|
26
|
+
TransientAPIError,
|
|
27
|
+
)
|
|
28
|
+
from core.logger import get_logger, log_api_request, log_exception
|
|
29
|
+
|
|
30
|
+
DEFAULT_TIMEOUT_SECONDS = 30
|
|
31
|
+
RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504}
|
|
32
|
+
HTTP_NO_CONTENT = 204
|
|
33
|
+
HTTP_UNAUTHORIZED = 401
|
|
34
|
+
HTTP_FORBIDDEN = 403
|
|
35
|
+
HTTP_NOT_FOUND = 404
|
|
36
|
+
HTTP_RATE_LIMITED = 429
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ProcoreClient:
|
|
40
|
+
"""HTTP client that handles Procore authentication, retries, and errors."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
settings: ProcoreSettings | None = None,
|
|
45
|
+
token_manager: TokenManager | None = None,
|
|
46
|
+
session: requests.Session | None = None,
|
|
47
|
+
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Initialize the Procore HTTP client.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
settings: Optional SDK settings. Defaults to environment-backed
|
|
53
|
+
settings.
|
|
54
|
+
token_manager: Optional token manager for bearer tokens.
|
|
55
|
+
session: Optional requests session.
|
|
56
|
+
timeout_seconds: Default timeout for HTTP requests.
|
|
57
|
+
"""
|
|
58
|
+
self._settings = settings or get_settings()
|
|
59
|
+
self._token_manager = token_manager or TokenManager()
|
|
60
|
+
self._session = session or requests.Session()
|
|
61
|
+
self._timeout_seconds = timeout_seconds
|
|
62
|
+
self._logger = get_logger("client")
|
|
63
|
+
self._current_attempt_count = 0
|
|
64
|
+
self._last_retry_count = 0
|
|
65
|
+
|
|
66
|
+
self._session.headers.update(
|
|
67
|
+
{
|
|
68
|
+
"Accept": "application/json",
|
|
69
|
+
"User-Agent": "procore-sdk-python/0.1.0",
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def get(
|
|
74
|
+
self,
|
|
75
|
+
path: str,
|
|
76
|
+
params: Mapping[str, Any] | None = None,
|
|
77
|
+
headers: Mapping[str, str] | None = None,
|
|
78
|
+
) -> Any:
|
|
79
|
+
"""Send a GET request to Procore."""
|
|
80
|
+
return self.request("GET", path, params=params, headers=headers)
|
|
81
|
+
|
|
82
|
+
def get_all(
|
|
83
|
+
self,
|
|
84
|
+
path: str,
|
|
85
|
+
params: Mapping[str, Any] | None = None,
|
|
86
|
+
headers: Mapping[str, str] | None = None,
|
|
87
|
+
) -> list[Any]:
|
|
88
|
+
"""Return all pages for a paginated Procore collection endpoint."""
|
|
89
|
+
collected: list[Any] = []
|
|
90
|
+
next_path: str | None = path
|
|
91
|
+
next_params: dict[str, Any] | None = dict(params or {})
|
|
92
|
+
|
|
93
|
+
while next_path is not None:
|
|
94
|
+
response = self._perform_request(
|
|
95
|
+
"GET",
|
|
96
|
+
next_path,
|
|
97
|
+
params=next_params,
|
|
98
|
+
headers=headers,
|
|
99
|
+
)
|
|
100
|
+
page_data = self._parse_response(response)
|
|
101
|
+
if isinstance(page_data, list):
|
|
102
|
+
collected.extend(page_data)
|
|
103
|
+
elif page_data is not None:
|
|
104
|
+
collected.append(page_data)
|
|
105
|
+
|
|
106
|
+
next_path, next_params = self._next_page(response, next_params)
|
|
107
|
+
|
|
108
|
+
return collected
|
|
109
|
+
|
|
110
|
+
def post(
|
|
111
|
+
self,
|
|
112
|
+
path: str,
|
|
113
|
+
json: Mapping[str, Any] | None = None,
|
|
114
|
+
data: Mapping[str, Any] | None = None,
|
|
115
|
+
headers: Mapping[str, str] | None = None,
|
|
116
|
+
) -> Any:
|
|
117
|
+
"""Send a POST request to Procore."""
|
|
118
|
+
return self.request("POST", path, json=json, data=data, headers=headers)
|
|
119
|
+
|
|
120
|
+
def put(
|
|
121
|
+
self,
|
|
122
|
+
path: str,
|
|
123
|
+
json: Mapping[str, Any] | None = None,
|
|
124
|
+
data: Mapping[str, Any] | None = None,
|
|
125
|
+
headers: Mapping[str, str] | None = None,
|
|
126
|
+
) -> Any:
|
|
127
|
+
"""Send a PUT request to Procore."""
|
|
128
|
+
return self.request("PUT", path, json=json, data=data, headers=headers)
|
|
129
|
+
|
|
130
|
+
def delete(
|
|
131
|
+
self,
|
|
132
|
+
path: str,
|
|
133
|
+
params: Mapping[str, Any] | None = None,
|
|
134
|
+
headers: Mapping[str, str] | None = None,
|
|
135
|
+
) -> Any:
|
|
136
|
+
"""Send a DELETE request to Procore."""
|
|
137
|
+
return self.request("DELETE", path, params=params, headers=headers)
|
|
138
|
+
|
|
139
|
+
def request(
|
|
140
|
+
self,
|
|
141
|
+
method: str,
|
|
142
|
+
path: str,
|
|
143
|
+
*,
|
|
144
|
+
params: Mapping[str, Any] | None = None,
|
|
145
|
+
json: Mapping[str, Any] | None = None,
|
|
146
|
+
data: Mapping[str, Any] | None = None,
|
|
147
|
+
headers: Mapping[str, str] | None = None,
|
|
148
|
+
timeout_seconds: int | None = None,
|
|
149
|
+
) -> Any:
|
|
150
|
+
"""Send an authenticated request and parse the response.
|
|
151
|
+
|
|
152
|
+
A 401 triggers one forced token refresh and one retry. Transient
|
|
153
|
+
failures are retried by ``tenacity`` before being raised.
|
|
154
|
+
"""
|
|
155
|
+
response = self._perform_request(
|
|
156
|
+
method,
|
|
157
|
+
path,
|
|
158
|
+
params=params,
|
|
159
|
+
json=json,
|
|
160
|
+
data=data,
|
|
161
|
+
headers=headers,
|
|
162
|
+
timeout_seconds=timeout_seconds,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
return self._parse_response(response)
|
|
166
|
+
|
|
167
|
+
def _perform_request(
|
|
168
|
+
self,
|
|
169
|
+
method: str,
|
|
170
|
+
path: str,
|
|
171
|
+
*,
|
|
172
|
+
params: Mapping[str, Any] | None = None,
|
|
173
|
+
json: Mapping[str, Any] | None = None,
|
|
174
|
+
data: Mapping[str, Any] | None = None,
|
|
175
|
+
headers: Mapping[str, str] | None = None,
|
|
176
|
+
timeout_seconds: int | None = None,
|
|
177
|
+
) -> requests.Response:
|
|
178
|
+
"""Send a request, handle 401 refresh, log, and validate status."""
|
|
179
|
+
started_at = perf_counter()
|
|
180
|
+
response: requests.Response | None = None
|
|
181
|
+
request_url = self._build_url(path)
|
|
182
|
+
request_logged = False
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
response = self._request_with_current_token(
|
|
186
|
+
method,
|
|
187
|
+
path,
|
|
188
|
+
params=params,
|
|
189
|
+
json=json,
|
|
190
|
+
data=data,
|
|
191
|
+
headers=headers,
|
|
192
|
+
timeout_seconds=timeout_seconds,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if response.status_code == HTTP_UNAUTHORIZED:
|
|
196
|
+
self._logger.info(
|
|
197
|
+
"Access token rejected; refreshing token and retrying."
|
|
198
|
+
)
|
|
199
|
+
self._token_manager.get_access_token(force_refresh=True)
|
|
200
|
+
response = self._request_with_current_token(
|
|
201
|
+
method,
|
|
202
|
+
path,
|
|
203
|
+
params=params,
|
|
204
|
+
json=json,
|
|
205
|
+
data=data,
|
|
206
|
+
headers=headers,
|
|
207
|
+
timeout_seconds=timeout_seconds,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
self._log_request(method, request_url, response, started_at)
|
|
211
|
+
request_logged = True
|
|
212
|
+
self._raise_for_status(response)
|
|
213
|
+
return response
|
|
214
|
+
except Exception as exc:
|
|
215
|
+
if not request_logged:
|
|
216
|
+
self._log_request(method, request_url, response, started_at)
|
|
217
|
+
log_exception(
|
|
218
|
+
self._logger,
|
|
219
|
+
exc=exc,
|
|
220
|
+
request_url=response.url if response is not None else request_url,
|
|
221
|
+
http_status=response.status_code if response is not None else None,
|
|
222
|
+
response_body=(
|
|
223
|
+
self._safe_response_body(response) if response is not None else None
|
|
224
|
+
),
|
|
225
|
+
)
|
|
226
|
+
raise
|
|
227
|
+
|
|
228
|
+
def _request_with_current_token(
|
|
229
|
+
self,
|
|
230
|
+
method: str,
|
|
231
|
+
path: str,
|
|
232
|
+
*,
|
|
233
|
+
params: Mapping[str, Any] | None = None,
|
|
234
|
+
json: Mapping[str, Any] | None = None,
|
|
235
|
+
data: Mapping[str, Any] | None = None,
|
|
236
|
+
headers: Mapping[str, str] | None = None,
|
|
237
|
+
timeout_seconds: int | None = None,
|
|
238
|
+
) -> requests.Response:
|
|
239
|
+
"""Attach the current bearer token and send a retryable request."""
|
|
240
|
+
access_token = self._token_manager.get_access_token()
|
|
241
|
+
self._current_attempt_count = 0
|
|
242
|
+
self._last_retry_count = 0
|
|
243
|
+
request_headers = {
|
|
244
|
+
"Authorization": f"Bearer {access_token}",
|
|
245
|
+
**dict(headers or {}),
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return self._send_with_retry(
|
|
249
|
+
method=method.upper(),
|
|
250
|
+
url=self._build_url(path),
|
|
251
|
+
params=params,
|
|
252
|
+
json=json,
|
|
253
|
+
data=data,
|
|
254
|
+
headers=request_headers,
|
|
255
|
+
timeout=timeout_seconds or self._timeout_seconds,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
@retry(
|
|
259
|
+
retry=retry_if_exception_type(
|
|
260
|
+
(requests.RequestException, RateLimitError, TransientAPIError)
|
|
261
|
+
),
|
|
262
|
+
wait=wait_exponential(multiplier=1, min=1, max=10),
|
|
263
|
+
stop=stop_after_attempt(3),
|
|
264
|
+
reraise=True,
|
|
265
|
+
)
|
|
266
|
+
def _send_with_retry(
|
|
267
|
+
self,
|
|
268
|
+
*,
|
|
269
|
+
method: str,
|
|
270
|
+
url: str,
|
|
271
|
+
params: Mapping[str, Any] | None,
|
|
272
|
+
json: Mapping[str, Any] | None,
|
|
273
|
+
data: Mapping[str, Any] | None,
|
|
274
|
+
headers: Mapping[str, str],
|
|
275
|
+
timeout: int,
|
|
276
|
+
) -> requests.Response:
|
|
277
|
+
"""Send an HTTP request and raise retryable failures."""
|
|
278
|
+
self._current_attempt_count += 1
|
|
279
|
+
self._last_retry_count = max(0, self._current_attempt_count - 1)
|
|
280
|
+
response = self._session.request(
|
|
281
|
+
method=method,
|
|
282
|
+
url=url,
|
|
283
|
+
params=params,
|
|
284
|
+
json=json,
|
|
285
|
+
data=data,
|
|
286
|
+
headers=dict(headers),
|
|
287
|
+
timeout=timeout,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
if response.status_code in RETRYABLE_STATUS_CODES:
|
|
291
|
+
error_message = self._response_error_message(response)
|
|
292
|
+
if response.status_code == HTTP_RATE_LIMITED:
|
|
293
|
+
raise RateLimitError(
|
|
294
|
+
error_message,
|
|
295
|
+
status_code=response.status_code,
|
|
296
|
+
response_body=self._safe_response_body(response),
|
|
297
|
+
)
|
|
298
|
+
raise TransientAPIError(
|
|
299
|
+
error_message,
|
|
300
|
+
status_code=response.status_code,
|
|
301
|
+
response_body=self._safe_response_body(response),
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
return response
|
|
305
|
+
|
|
306
|
+
def _log_request(
|
|
307
|
+
self,
|
|
308
|
+
method: str,
|
|
309
|
+
request_url: str,
|
|
310
|
+
response: requests.Response | None,
|
|
311
|
+
started_at: float,
|
|
312
|
+
) -> None:
|
|
313
|
+
"""Log a completed or failed request without sensitive headers."""
|
|
314
|
+
elapsed_ms = (perf_counter() - started_at) * 1000
|
|
315
|
+
log_api_request(
|
|
316
|
+
self._logger,
|
|
317
|
+
method=method.upper(),
|
|
318
|
+
endpoint=response.url if response is not None else request_url,
|
|
319
|
+
status_code=response.status_code if response is not None else None,
|
|
320
|
+
elapsed_ms=elapsed_ms,
|
|
321
|
+
retry_count=self._last_retry_count,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def _build_url(self, path: str) -> str:
|
|
325
|
+
"""Build a full request URL from an API path or absolute URL."""
|
|
326
|
+
if path.startswith(("http://", "https://")):
|
|
327
|
+
return path
|
|
328
|
+
|
|
329
|
+
base_url = f"{self._settings.api_base}/"
|
|
330
|
+
normalized_path = path.lstrip("/")
|
|
331
|
+
return urljoin(base_url, normalized_path)
|
|
332
|
+
|
|
333
|
+
def _raise_for_status(self, response: requests.Response) -> None:
|
|
334
|
+
"""Map unsuccessful responses to SDK exceptions."""
|
|
335
|
+
if response.ok:
|
|
336
|
+
return
|
|
337
|
+
|
|
338
|
+
message = self._response_error_message(response)
|
|
339
|
+
body = self._safe_response_body(response)
|
|
340
|
+
|
|
341
|
+
if response.status_code == HTTP_UNAUTHORIZED:
|
|
342
|
+
raise AuthenticationError(message)
|
|
343
|
+
if response.status_code == HTTP_FORBIDDEN:
|
|
344
|
+
raise AuthorizationError(message)
|
|
345
|
+
if response.status_code == HTTP_NOT_FOUND:
|
|
346
|
+
raise ResourceNotFoundError(
|
|
347
|
+
message,
|
|
348
|
+
status_code=response.status_code,
|
|
349
|
+
response_body=body,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
raise ProcoreAPIError(
|
|
353
|
+
message,
|
|
354
|
+
status_code=response.status_code,
|
|
355
|
+
response_body=body,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
@staticmethod
|
|
359
|
+
def _parse_response(response: requests.Response) -> Any:
|
|
360
|
+
"""Return JSON response data, text, or ``None`` for empty responses."""
|
|
361
|
+
if response.status_code == HTTP_NO_CONTENT or not response.content:
|
|
362
|
+
return None
|
|
363
|
+
|
|
364
|
+
content_type = response.headers.get("Content-Type", "")
|
|
365
|
+
if "application/json" in content_type:
|
|
366
|
+
return response.json()
|
|
367
|
+
|
|
368
|
+
return response.text
|
|
369
|
+
|
|
370
|
+
@staticmethod
|
|
371
|
+
def _safe_response_body(response: requests.Response) -> Any:
|
|
372
|
+
"""Return response body content for errors without assuming JSON."""
|
|
373
|
+
try:
|
|
374
|
+
return response.json()
|
|
375
|
+
except ValueError:
|
|
376
|
+
return response.text
|
|
377
|
+
|
|
378
|
+
def _response_error_message(self, response: requests.Response) -> str:
|
|
379
|
+
"""Build a concise message for an unsuccessful Procore response."""
|
|
380
|
+
request_method = response.request.method if response.request else "UNKNOWN"
|
|
381
|
+
return (
|
|
382
|
+
f"Procore API request failed with status {response.status_code} "
|
|
383
|
+
f"for {request_method} {response.url}"
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
@staticmethod
|
|
387
|
+
def _next_page(
|
|
388
|
+
response: requests.Response,
|
|
389
|
+
current_params: Mapping[str, Any] | None,
|
|
390
|
+
) -> tuple[str | None, dict[str, Any] | None]:
|
|
391
|
+
"""Return the next page path and params from Procore pagination headers."""
|
|
392
|
+
link_header = response.headers.get("Link")
|
|
393
|
+
if link_header:
|
|
394
|
+
next_url = ProcoreClient._next_link_url(link_header)
|
|
395
|
+
if next_url:
|
|
396
|
+
return next_url, None
|
|
397
|
+
|
|
398
|
+
next_page = response.headers.get("X-Next-Page")
|
|
399
|
+
if next_page:
|
|
400
|
+
next_page = next_page.strip()
|
|
401
|
+
if next_page:
|
|
402
|
+
params = dict(current_params or {})
|
|
403
|
+
params["page"] = int(next_page) if next_page.isdigit() else next_page
|
|
404
|
+
return response.request.path_url.split("?", 1)[0], params
|
|
405
|
+
|
|
406
|
+
return None, None
|
|
407
|
+
|
|
408
|
+
@staticmethod
|
|
409
|
+
def _next_link_url(link_header: str) -> str | None:
|
|
410
|
+
"""Parse a RFC 5988 Link header and return the rel=next URL."""
|
|
411
|
+
for part in link_header.split(","):
|
|
412
|
+
section = part.strip()
|
|
413
|
+
if 'rel="next"' not in section and "rel=next" not in section:
|
|
414
|
+
continue
|
|
415
|
+
if not section.startswith("<") or ">" not in section:
|
|
416
|
+
continue
|
|
417
|
+
next_url = section[1 : section.index(">")]
|
|
418
|
+
parsed = urlparse(next_url)
|
|
419
|
+
query = parse_qs(parsed.query)
|
|
420
|
+
if parsed.scheme and parsed.netloc:
|
|
421
|
+
return next_url
|
|
422
|
+
if query:
|
|
423
|
+
return next_url
|
|
424
|
+
return next_url
|
|
425
|
+
return None
|
core/config.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Application configuration for the Procore SDK.
|
|
2
|
+
|
|
3
|
+
This module is the single source of truth for environment-backed settings.
|
|
4
|
+
Values are loaded from a local ``.env`` file when present and validated before
|
|
5
|
+
the rest of the SDK uses them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from functools import lru_cache
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from dotenv import load_dotenv
|
|
16
|
+
from pydantic import BaseModel, Field, SecretStr, ValidationError, field_validator
|
|
17
|
+
|
|
18
|
+
from core.exceptions import ConfigurationError
|
|
19
|
+
|
|
20
|
+
ENV_FILE_NAME = ".env"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProcoreSettings(BaseModel):
|
|
24
|
+
"""Validated runtime settings for Procore API access."""
|
|
25
|
+
|
|
26
|
+
client_id: str = Field(..., min_length=1)
|
|
27
|
+
client_secret: SecretStr
|
|
28
|
+
redirect_uri: str = Field(..., min_length=1)
|
|
29
|
+
login_url: str = Field(..., min_length=1)
|
|
30
|
+
api_base: str = Field(..., min_length=1)
|
|
31
|
+
company_id: int = Field(..., gt=0)
|
|
32
|
+
|
|
33
|
+
@field_validator(
|
|
34
|
+
"client_id",
|
|
35
|
+
"client_secret",
|
|
36
|
+
"redirect_uri",
|
|
37
|
+
"login_url",
|
|
38
|
+
"api_base",
|
|
39
|
+
mode="before",
|
|
40
|
+
)
|
|
41
|
+
@classmethod
|
|
42
|
+
def _strip_required_string(cls, value: Any) -> str:
|
|
43
|
+
"""Normalize string values and reject empty input."""
|
|
44
|
+
if value is None:
|
|
45
|
+
raise ValueError("value is required")
|
|
46
|
+
|
|
47
|
+
normalized = str(value).strip()
|
|
48
|
+
if not normalized:
|
|
49
|
+
raise ValueError("value cannot be empty")
|
|
50
|
+
|
|
51
|
+
return normalized
|
|
52
|
+
|
|
53
|
+
@field_validator("login_url", "api_base")
|
|
54
|
+
@classmethod
|
|
55
|
+
def _normalize_base_url(cls, value: str) -> str:
|
|
56
|
+
"""Remove trailing slashes so endpoint paths compose predictably."""
|
|
57
|
+
return value.rstrip("/")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _project_root() -> Path:
|
|
61
|
+
"""Return the project root directory containing this module."""
|
|
62
|
+
return Path(__file__).resolve().parents[1]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _env_path() -> Path:
|
|
66
|
+
"""Return the expected path to the project ``.env`` file."""
|
|
67
|
+
return _project_root() / ENV_FILE_NAME
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _read_environment() -> dict[str, str | None]:
|
|
71
|
+
"""Read supported configuration keys from the process environment."""
|
|
72
|
+
return {
|
|
73
|
+
"client_id": os.getenv("PROCORE_CLIENT_ID"),
|
|
74
|
+
"client_secret": os.getenv("PROCORE_CLIENT_SECRET"),
|
|
75
|
+
"redirect_uri": os.getenv("PROCORE_REDIRECT_URI"),
|
|
76
|
+
"login_url": os.getenv("PROCORE_LOGIN_URL"),
|
|
77
|
+
"api_base": os.getenv("PROCORE_API_BASE"),
|
|
78
|
+
"company_id": os.getenv("PROCORE_COMPANY_ID"),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@lru_cache(maxsize=1)
|
|
83
|
+
def get_settings() -> ProcoreSettings:
|
|
84
|
+
"""Load and validate SDK settings from environment variables.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
A cached ``ProcoreSettings`` instance.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
ConfigurationError: If any required setting is missing or invalid.
|
|
91
|
+
"""
|
|
92
|
+
load_dotenv(dotenv_path=_env_path(), override=False)
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
return ProcoreSettings.model_validate(_read_environment())
|
|
96
|
+
except ValidationError as exc:
|
|
97
|
+
raise ConfigurationError(f"Invalid Procore SDK configuration: {exc}") from exc
|
core/endpoints.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Endpoint path definitions for the Procore REST API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
API_V1 = "/rest/v1.0"
|
|
6
|
+
API_V1_1 = "/rest/v1.1"
|
|
7
|
+
|
|
8
|
+
COMPANIES = f"{API_V1}/companies"
|
|
9
|
+
PROJECTS = f"{API_V1}/companies/{{company_id}}/projects"
|
|
10
|
+
RFIS = f"{API_V1_1}/projects/{{project_id}}/rfis"
|
|
11
|
+
RFI = f"{API_V1_1}/projects/{{project_id}}/rfis/{{rfi_id}}"
|
|
12
|
+
SUBMITTALS = f"{API_V1_1}/projects/{{project_id}}/submittals"
|
|
13
|
+
SUBMITTAL = f"{API_V1_1}/projects/{{project_id}}/submittals/{{submittal_id}}"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def companies() -> str:
|
|
17
|
+
"""Return the companies collection endpoint."""
|
|
18
|
+
return COMPANIES
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def projects(company_id: int) -> str:
|
|
22
|
+
"""Return the projects collection endpoint for a company."""
|
|
23
|
+
return PROJECTS.format(company_id=company_id)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def rfis(project_id: int) -> str:
|
|
27
|
+
"""Return the RFIs collection endpoint for a project."""
|
|
28
|
+
return RFIS.format(project_id=project_id)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def rfi(project_id: int, rfi_id: int) -> str:
|
|
32
|
+
"""Return the endpoint for a single RFI."""
|
|
33
|
+
return RFI.format(project_id=project_id, rfi_id=rfi_id)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def submittals(project_id: int) -> str:
|
|
37
|
+
"""Return the submittals collection endpoint for a project."""
|
|
38
|
+
return SUBMITTALS.format(project_id=project_id)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def submittal(project_id: int, submittal_id: int) -> str:
|
|
42
|
+
"""Return the endpoint for a single submittal."""
|
|
43
|
+
return SUBMITTAL.format(project_id=project_id, submittal_id=submittal_id)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Endpoints:
|
|
47
|
+
"""Backward-compatible namespace for endpoint path templates."""
|
|
48
|
+
|
|
49
|
+
COMPANIES = COMPANIES
|
|
50
|
+
PROJECTS = PROJECTS
|
|
51
|
+
RFIS = RFIS
|
|
52
|
+
RFI = RFI
|
|
53
|
+
SUBMITTALS = SUBMITTALS
|
|
54
|
+
SUBMITTAL = SUBMITTAL
|
core/exceptions.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Custom exceptions raised by the Procore SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ProcoreError(Exception):
|
|
9
|
+
"""Base exception for all SDK-specific errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfigurationError(ProcoreError):
|
|
13
|
+
"""Raised when SDK configuration is missing or invalid."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthenticationError(ProcoreError):
|
|
17
|
+
"""Raised when OAuth authentication or token refresh fails."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuthorizationError(ProcoreError):
|
|
21
|
+
"""Raised when Procore denies access to an authenticated request."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ValidationError(ProcoreError):
|
|
25
|
+
"""Raised when input or response validation fails."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ProcoreAPIError(ProcoreError):
|
|
29
|
+
"""Raised when the Procore API returns an unsuccessful response."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
message: str,
|
|
34
|
+
status_code: int | None = None,
|
|
35
|
+
response_body: Any | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Initialize an API error.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
message: Human-readable error summary.
|
|
41
|
+
status_code: Optional HTTP status code returned by Procore.
|
|
42
|
+
response_body: Optional parsed or raw response body.
|
|
43
|
+
"""
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.status_code = status_code
|
|
46
|
+
self.response_body = response_body
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ResourceNotFoundError(ProcoreAPIError):
|
|
50
|
+
"""Raised when a requested Procore resource cannot be found."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RateLimitError(ProcoreAPIError):
|
|
54
|
+
"""Raised when Procore rate-limits a request."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TransientAPIError(ProcoreAPIError):
|
|
58
|
+
"""Raised for transient Procore API failures that may succeed later."""
|