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.
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."""