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.
@@ -0,0 +1,27 @@
1
+ app.py,sha256=Ov7JpwDrg0hczDCGLg-xNAKOYfi0fKJTEVxva6mwGN4,5565
2
+ auth/__init__.py,sha256=5GxjVbiveFzp_ul6NKkopXI7MwpDD--PBNcqrTw3oss,1282
3
+ auth/oauth.py,sha256=faFM5gdgFmqj42BCufsiQzPjV_s-l9aidq69SHc9WhI,5740
4
+ auth/token_manager.py,sha256=lKpQyzBn111DhB1F_EybrErPNvvRfPYpQgp5ZlDDnfk,3713
5
+ auth/token_store.py,sha256=X0I-Zg4AKq9EL55o8Gd-pWvn8RTi3LSqk4-GQAL6Uh0,5349
6
+ core/__init__.py,sha256=RqbfJLARs_8hpR1P5qmiYcoUdkqwspWaEeN6dBcScEQ,1414
7
+ core/client.py,sha256=CnnZUK9sQU_ZuzCHjh6dep606crHHVLE_fuF8bzJGS0,14438
8
+ core/config.py,sha256=OTrA0kq3bpTgYqcomB9zFgJcuqhazSKXO4lTISx6MoE,2956
9
+ core/endpoints.py,sha256=_l8S2wu5w2uvYVd4QeeQ5duyU5oJWDUe3pPQ-WWO9eg,1612
10
+ core/exceptions.py,sha256=xndIP033kKas0VJN7oQ1L0bFZ-zsN-s_UwpThEokb3E,1585
11
+ core/logger.py,sha256=OtPdHNZgffsSwrEVBhSwAPwbs3_MuD3RtkQdo9-2zGk,4016
12
+ models/__init__.py,sha256=2IwIIJAo1itAnU_RCqnvWMwEj1JYC-SXopF17YCHhh4,383
13
+ models/base.py,sha256=_qCHmEpWBuKOajD1AFKVEMNwyw3B9WQpQqIkeROl804,308
14
+ models/resources.py,sha256=2NXNqx3UQh1gYyQyqLzt5PFVGsdTPM6TJw979A9xKgo,2009
15
+ parser/__init__.py,sha256=ka0WVtIT1NgzQIKPP1FESTpGjW9fFxMuPRjAikzEm3s,329
16
+ parser/email_parser.py,sha256=c6xG4oDRJMt870r5kAgoNfvepgbgOkskGgVV1ai20PI,5807
17
+ services/__init__.py,sha256=cEzm2U-gSb-0YmUR-hwzkNwPj2OznbFXo1Eqm-vHAxQ,879
18
+ services/companies.py,sha256=9y2zkqv-U5Jh9k4QZyLgSmKhWNwWURZz_hWCEioyRWM,1046
19
+ services/files.py,sha256=I_mfiEn6NII8938R1XQcM4Wfh8sFHcRcf8p9bstt5t8,9884
20
+ services/projects.py,sha256=7IK8d_TpT0qI5q1fMHl2QFeiCt8ynu7XTnxtIFI6EBQ,2769
21
+ services/rfis.py,sha256=jc-HrJHvtRHjd-CkZySTKfWeh9HTZqLtv7C1-byLIr0,4856
22
+ services/submittals.py,sha256=J-hMU7l3e0NqoH5I2UtDEvheENNigYxAvIlIHi3i62E,4733
23
+ pyprocore-1.0.0.dist-info/METADATA,sha256=cszEl4t-FObr-kTPlPYoksN4PGE7RvpxZBHrK1urSw4,6348
24
+ pyprocore-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
25
+ pyprocore-1.0.0.dist-info/entry_points.txt,sha256=dLRkcqKECUJsQKG0uh6lNQxe0YCYHbXVlTZV1A9UQOM,41
26
+ pyprocore-1.0.0.dist-info/top_level.txt,sha256=B6v51zzul23u5voBAWwqzqFQG33BPcCbr7EeGiFpn7k,37
27
+ pyprocore-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ procore-sdk = app:main
@@ -0,0 +1,6 @@
1
+ app
2
+ auth
3
+ core
4
+ models
5
+ parser
6
+ services
services/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """Service layer exports for Procore resources."""
2
+
3
+ from services.companies import CompaniesService, list_companies
4
+ from services.files import FileDownloadService, attachment_filename, download_url
5
+ from services.projects import ProjectsService, get_project, list_projects
6
+ from services.rfis import RFIsService, download_rfi_attachments, get_rfi, list_rfis
7
+ from services.submittals import (
8
+ SubmittalsService,
9
+ download_submittal_attachments,
10
+ get_submittal,
11
+ list_submittals,
12
+ )
13
+
14
+ __all__ = [
15
+ "CompaniesService",
16
+ "FileDownloadService",
17
+ "ProjectsService",
18
+ "RFIsService",
19
+ "SubmittalsService",
20
+ "attachment_filename",
21
+ "download_rfi_attachments",
22
+ "download_submittal_attachments",
23
+ "download_url",
24
+ "get_project",
25
+ "get_rfi",
26
+ "get_submittal",
27
+ "list_companies",
28
+ "list_projects",
29
+ "list_rfis",
30
+ "list_submittals",
31
+ ]
services/companies.py ADDED
@@ -0,0 +1,31 @@
1
+ """Company service for the Procore SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from core import endpoints
6
+ from core.client import ProcoreClient
7
+ from models import Company
8
+
9
+
10
+ class CompaniesService:
11
+ """Service for Procore company resources."""
12
+
13
+ def __init__(self, client: ProcoreClient | None = None) -> None:
14
+ """Initialize the service.
15
+
16
+ Args:
17
+ client: Optional shared Procore HTTP client.
18
+ """
19
+ self._client = client or ProcoreClient()
20
+
21
+ def list_companies(self) -> list[Company]:
22
+ """Return companies available to the authenticated Procore user."""
23
+ response = self._client.get_all(endpoints.companies())
24
+ if isinstance(response, list):
25
+ return [Company.model_validate(company) for company in response]
26
+ return [Company.model_validate(response)]
27
+
28
+
29
+ def list_companies(client: ProcoreClient | None = None) -> list[Company]:
30
+ """Return companies available to the authenticated Procore user."""
31
+ return CompaniesService(client=client).list_companies()
services/files.py ADDED
@@ -0,0 +1,281 @@
1
+ """Shared file download helpers for Procore resources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Iterable, Mapping
7
+ from pathlib import Path
8
+ from typing import Any
9
+ from urllib.parse import unquote, urlparse
10
+
11
+ import requests
12
+ from tenacity import (
13
+ retry,
14
+ retry_if_exception_type,
15
+ stop_after_attempt,
16
+ wait_exponential,
17
+ )
18
+
19
+ from auth.token_manager import TokenManager
20
+ from core.client import DEFAULT_TIMEOUT_SECONDS
21
+ from core.exceptions import ProcoreAPIError, ValidationError
22
+ from core.logger import get_logger, log_exception, structured_message
23
+
24
+ DEFAULT_DOWNLOAD_DIR = Path(__file__).resolve().parents[1] / "downloads"
25
+ DEFAULT_CHUNK_SIZE = 1024 * 1024
26
+ UNSAFE_FILENAME_PATTERN = re.compile(r"[^A-Za-z0-9._ -]+")
27
+
28
+
29
+ class FileDownloadService:
30
+ """Service for downloading signed Procore attachment URLs."""
31
+
32
+ def __init__(
33
+ self,
34
+ token_manager: TokenManager | None = None,
35
+ session: requests.Session | None = None,
36
+ timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
37
+ chunk_size: int = DEFAULT_CHUNK_SIZE,
38
+ ) -> None:
39
+ """Initialize the file download service.
40
+
41
+ Args:
42
+ token_manager: Optional token manager for bearer tokens.
43
+ session: Optional HTTP session for file requests.
44
+ timeout_seconds: Timeout for file requests.
45
+ chunk_size: Number of bytes written per streamed chunk.
46
+ """
47
+ self._token_manager = token_manager or TokenManager()
48
+ self._session = session or requests.Session()
49
+ self._timeout_seconds = timeout_seconds
50
+ self._chunk_size = chunk_size
51
+ self._logger = get_logger("files")
52
+
53
+ def download_url(
54
+ self,
55
+ url: str,
56
+ destination: Path | str,
57
+ *,
58
+ overwrite: bool = False,
59
+ ) -> Path:
60
+ """Download a signed Procore URL to a destination path.
61
+
62
+ Args:
63
+ url: Signed Procore attachment URL.
64
+ destination: Full local destination path.
65
+ overwrite: Whether to overwrite an existing destination file.
66
+
67
+ Returns:
68
+ The saved file path.
69
+ """
70
+ normalized_url = url.strip()
71
+ if not normalized_url:
72
+ raise ValidationError("Attachment URL cannot be empty.")
73
+
74
+ destination_path = Path(destination)
75
+ if destination_path.name in {"", ".", ".."}:
76
+ raise ValidationError("Attachment destination must include a filename.")
77
+
78
+ destination_path = destination_path.with_name(
79
+ sanitize_filename(destination_path.name)
80
+ )
81
+ if destination_path.exists() and not overwrite:
82
+ self._logger.info(
83
+ structured_message(
84
+ "download_skipped",
85
+ path=str(destination_path),
86
+ reason="exists",
87
+ )
88
+ )
89
+ return destination_path
90
+
91
+ destination_path.parent.mkdir(parents=True, exist_ok=True)
92
+
93
+ try:
94
+ response = self._get_with_retry(normalized_url)
95
+ self._raise_for_download_status(response, normalized_url)
96
+ self._write_stream(response, destination_path)
97
+ except requests.RequestException as exc:
98
+ log_exception(
99
+ self._logger,
100
+ exc=exc,
101
+ request_url=normalized_url,
102
+ http_status=getattr(
103
+ locals().get("response", None), "status_code", None
104
+ ),
105
+ response_body=getattr(locals().get("response", None), "text", None),
106
+ )
107
+ raise ProcoreAPIError(
108
+ f"Attachment download failed for {normalized_url}: {exc}"
109
+ ) from exc
110
+ except Exception as exc:
111
+ log_exception(
112
+ self._logger,
113
+ exc=exc,
114
+ request_url=normalized_url,
115
+ http_status=getattr(
116
+ locals().get("response", None), "status_code", None
117
+ ),
118
+ response_body=getattr(locals().get("response", None), "text", None),
119
+ )
120
+ raise
121
+
122
+ self._logger.info(
123
+ structured_message(
124
+ "download_complete",
125
+ path=str(destination_path),
126
+ bytes=destination_path.stat().st_size,
127
+ )
128
+ )
129
+ return destination_path
130
+
131
+ def download_attachment(
132
+ self,
133
+ attachment: Mapping[str, Any],
134
+ destination_dir: Path | str,
135
+ fallback_name: str,
136
+ *,
137
+ overwrite: bool = False,
138
+ ) -> Path | None:
139
+ """Download an attachment mapping that contains a ``url`` value.
140
+
141
+ Args:
142
+ attachment: Procore attachment metadata.
143
+ destination_dir: Local directory where the file should be saved.
144
+ fallback_name: Filename used when metadata and URL do not provide
145
+ one.
146
+ overwrite: Whether to overwrite an existing destination file.
147
+
148
+ Returns:
149
+ The saved file path, or ``None`` when the attachment has no URL.
150
+ """
151
+ url = attachment.get("url")
152
+ if not isinstance(url, str) or not url.strip():
153
+ return None
154
+
155
+ filename = attachment_filename(attachment, fallback_name)
156
+ return self.download_url(
157
+ url,
158
+ Path(destination_dir) / filename,
159
+ overwrite=overwrite,
160
+ )
161
+
162
+ def download_attachments(
163
+ self,
164
+ attachments: Iterable[Mapping[str, Any]],
165
+ destination_dir: Path | str,
166
+ *,
167
+ fallback_prefix: str = "attachment",
168
+ overwrite: bool = False,
169
+ ) -> list[Path]:
170
+ """Download multiple attachment mappings to a destination directory."""
171
+ downloaded_files: list[Path] = []
172
+ for index, attachment in enumerate(attachments, start=1):
173
+ downloaded_file = self.download_attachment(
174
+ attachment,
175
+ destination_dir,
176
+ fallback_name=f"{fallback_prefix}-{index}",
177
+ overwrite=overwrite,
178
+ )
179
+ if downloaded_file is not None:
180
+ downloaded_files.append(downloaded_file)
181
+ return downloaded_files
182
+
183
+ @retry(
184
+ retry=retry_if_exception_type((requests.RequestException, ProcoreAPIError)),
185
+ wait=wait_exponential(multiplier=1, min=1, max=10),
186
+ stop=stop_after_attempt(3),
187
+ reraise=True,
188
+ )
189
+ def _get_with_retry(self, url: str) -> requests.Response:
190
+ """GET a signed URL with retry support."""
191
+ headers = {"Authorization": f"Bearer {self._token_manager.get_access_token()}"}
192
+ response = self._session.get(
193
+ url,
194
+ headers=headers,
195
+ stream=True,
196
+ timeout=self._timeout_seconds,
197
+ )
198
+ if response.status_code >= 500 or response.status_code == 429:
199
+ raise ProcoreAPIError(
200
+ f"Attachment download failed with status {response.status_code} for {url}",
201
+ status_code=response.status_code,
202
+ response_body=response.text,
203
+ )
204
+ return response
205
+
206
+ @staticmethod
207
+ def _raise_for_download_status(response: requests.Response, url: str) -> None:
208
+ """Raise when a download response is unsuccessful."""
209
+ if response.ok:
210
+ return
211
+ raise ProcoreAPIError(
212
+ f"Attachment download failed with status {response.status_code} for {url}",
213
+ status_code=response.status_code,
214
+ response_body=response.text,
215
+ )
216
+
217
+ def _write_stream(
218
+ self, response: requests.Response, destination_path: Path
219
+ ) -> None:
220
+ """Write a streamed response to disk in chunks."""
221
+ temporary_path = destination_path.with_suffix(f"{destination_path.suffix}.tmp")
222
+ bytes_written = 0
223
+ try:
224
+ with temporary_path.open("wb") as file_handle:
225
+ for chunk in response.iter_content(chunk_size=self._chunk_size):
226
+ if not chunk:
227
+ continue
228
+ file_handle.write(chunk)
229
+ bytes_written += len(chunk)
230
+ self._logger.info(
231
+ structured_message(
232
+ "download_progress",
233
+ path=str(destination_path),
234
+ bytes=bytes_written,
235
+ )
236
+ )
237
+ temporary_path.replace(destination_path)
238
+ except OSError as exc:
239
+ raise ProcoreAPIError(
240
+ f"Unable to save attachment {destination_path}: {exc}"
241
+ ) from exc
242
+
243
+
244
+ def sanitize_filename(filename: str) -> str:
245
+ """Return a filesystem-safe filename."""
246
+ candidate = Path(filename.strip()).name
247
+ candidate = UNSAFE_FILENAME_PATTERN.sub("_", candidate)
248
+ candidate = candidate.strip(" .")
249
+ return candidate or "attachment"
250
+
251
+
252
+ def attachment_filename(attachment: Mapping[str, Any], fallback_name: str) -> str:
253
+ """Return a safe filename from attachment metadata or URL path."""
254
+ for key in ("filename", "file_name", "name"):
255
+ value = attachment.get(key)
256
+ if isinstance(value, str) and value.strip():
257
+ return sanitize_filename(value)
258
+
259
+ url = attachment.get("url")
260
+ if isinstance(url, str) and url.strip():
261
+ parsed_path = unquote(urlparse(url).path)
262
+ path_name = Path(parsed_path).name
263
+ if path_name:
264
+ return sanitize_filename(path_name)
265
+
266
+ return sanitize_filename(fallback_name)
267
+
268
+
269
+ def download_url(
270
+ url: str,
271
+ destination: Path | str,
272
+ token_manager: TokenManager | None = None,
273
+ *,
274
+ overwrite: bool = False,
275
+ ) -> Path:
276
+ """Download a signed Procore URL with the default file service."""
277
+ return FileDownloadService(token_manager=token_manager).download_url(
278
+ url,
279
+ destination,
280
+ overwrite=overwrite,
281
+ )
services/projects.py ADDED
@@ -0,0 +1,87 @@
1
+ """Project service for the Procore SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from core import endpoints
6
+ from core.client import ProcoreClient
7
+ from core.config import ProcoreSettings, get_settings
8
+ from core.exceptions import ResourceNotFoundError, ValidationError
9
+ from models import Project
10
+
11
+
12
+ class ProjectsService:
13
+ """Service for Procore project resources."""
14
+
15
+ def __init__(
16
+ self,
17
+ client: ProcoreClient | None = None,
18
+ settings: ProcoreSettings | None = None,
19
+ ) -> None:
20
+ """Initialize the service.
21
+
22
+ Args:
23
+ client: Optional shared Procore HTTP client.
24
+ settings: Optional SDK settings. Used for the default company ID.
25
+ """
26
+ self._client = client or ProcoreClient()
27
+ self._settings = settings or get_settings()
28
+
29
+ def list_projects(self, company_id: int) -> list[Project]:
30
+ """Return projects for a Procore company.
31
+
32
+ Args:
33
+ company_id: Procore company ID.
34
+
35
+ Returns:
36
+ A list of project records.
37
+ """
38
+ if company_id <= 0:
39
+ raise ValidationError("company_id must be a positive integer.")
40
+
41
+ response = self._client.get_all(endpoints.projects(company_id))
42
+ if isinstance(response, list):
43
+ return [Project.model_validate(project) for project in response]
44
+ return [Project.model_validate(response)]
45
+
46
+ def get_project(self, project_id: int) -> Project:
47
+ """Return a single project from the configured company.
48
+
49
+ This uses the verified company projects endpoint and finds the requested
50
+ project locally instead of relying on an unverified single-project path.
51
+
52
+ Args:
53
+ project_id: Procore project ID.
54
+
55
+ Returns:
56
+ The matching project record.
57
+
58
+ Raises:
59
+ ResourceNotFoundError: If the configured company does not contain
60
+ the requested project.
61
+ """
62
+ if project_id <= 0:
63
+ raise ValidationError("project_id must be a positive integer.")
64
+
65
+ for project in self.list_projects(self._settings.company_id):
66
+ if project.id == project_id:
67
+ return project
68
+
69
+ raise ResourceNotFoundError(
70
+ f"Project {project_id} was not found for company {self._settings.company_id}."
71
+ )
72
+
73
+
74
+ def list_projects(
75
+ company_id: int,
76
+ client: ProcoreClient | None = None,
77
+ ) -> list[Project]:
78
+ """Return projects for a Procore company."""
79
+ return ProjectsService(client=client).list_projects(company_id)
80
+
81
+
82
+ def get_project(
83
+ project_id: int,
84
+ client: ProcoreClient | None = None,
85
+ ) -> Project:
86
+ """Return a single project from the configured company."""
87
+ return ProjectsService(client=client).get_project(project_id)
services/rfis.py ADDED
@@ -0,0 +1,147 @@
1
+ """RFI service for the Procore SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from core import endpoints
10
+ from core.client import ProcoreClient
11
+ from core.exceptions import ValidationError
12
+ from models import RFI
13
+ from services.files import FileDownloadService
14
+
15
+ DEFAULT_DOWNLOAD_DIR = Path(__file__).resolve().parents[1] / "downloads" / "rfis"
16
+
17
+
18
+ class RFIsService:
19
+ """Service for Procore RFI resources."""
20
+
21
+ def __init__(
22
+ self,
23
+ client: ProcoreClient | None = None,
24
+ file_service: FileDownloadService | None = None,
25
+ ) -> None:
26
+ """Initialize the service.
27
+
28
+ Args:
29
+ client: Optional shared Procore HTTP client.
30
+ file_service: Optional shared file download service.
31
+ """
32
+ self._client = client or ProcoreClient()
33
+ self._file_service = file_service or FileDownloadService()
34
+
35
+ def list_rfis(self, project_id: int) -> list[RFI]:
36
+ """Return RFIs for a Procore project."""
37
+ self._validate_positive_id(project_id, "project_id")
38
+
39
+ response = self._client.get_all(endpoints.rfis(project_id))
40
+ if isinstance(response, list):
41
+ return [RFI.model_validate(rfi) for rfi in response]
42
+ return [RFI.model_validate(response)]
43
+
44
+ def get_rfi(self, project_id: int, rfi_id: int) -> RFI:
45
+ """Return a single RFI for a Procore project."""
46
+ self._validate_positive_id(project_id, "project_id")
47
+ self._validate_positive_id(rfi_id, "rfi_id")
48
+
49
+ response = self._client.get(endpoints.rfi(project_id, rfi_id))
50
+ if not isinstance(response, dict):
51
+ raise ValidationError("Expected Procore RFI response to be an object.")
52
+ return RFI.model_validate(response)
53
+
54
+ def download_rfi_attachments(
55
+ self,
56
+ project_id: int,
57
+ rfi_id: int,
58
+ destination_dir: Path | str | None = None,
59
+ ) -> list[Path]:
60
+ """Download all attachments from an RFI's questions.
61
+
62
+ RFI attachment URLs are read from ``questions[].attachments[].url``.
63
+
64
+ Args:
65
+ project_id: Procore project ID.
66
+ rfi_id: Procore RFI ID.
67
+ destination_dir: Optional directory to save files. Defaults to
68
+ ``downloads/rfis/{rfi_id}``.
69
+
70
+ Returns:
71
+ Paths to downloaded files.
72
+ """
73
+ rfi = self.get_rfi(project_id, rfi_id)
74
+ attachments = self._extract_question_attachments(rfi.model_dump())
75
+ output_dir = (
76
+ Path(destination_dir)
77
+ if destination_dir
78
+ else DEFAULT_DOWNLOAD_DIR / str(rfi_id)
79
+ )
80
+ output_dir.mkdir(parents=True, exist_ok=True)
81
+
82
+ return self._file_service.download_attachments(
83
+ attachments,
84
+ output_dir,
85
+ fallback_prefix=f"rfi-{rfi_id}",
86
+ )
87
+
88
+ @staticmethod
89
+ def _extract_question_attachments(rfi: Mapping[str, Any]) -> list[dict[str, Any]]:
90
+ """Extract ``questions[].attachments[]`` dictionaries from an RFI."""
91
+ questions = rfi.get("questions", [])
92
+ if not isinstance(questions, Sequence) or isinstance(questions, (str, bytes)):
93
+ return []
94
+
95
+ attachments: list[dict[str, Any]] = []
96
+ for question in questions:
97
+ if not isinstance(question, Mapping):
98
+ continue
99
+
100
+ question_attachments = question.get("attachments", [])
101
+ if not isinstance(question_attachments, Sequence) or isinstance(
102
+ question_attachments,
103
+ (str, bytes),
104
+ ):
105
+ continue
106
+
107
+ attachments.extend(
108
+ dict(attachment)
109
+ for attachment in question_attachments
110
+ if isinstance(attachment, Mapping)
111
+ )
112
+
113
+ return attachments
114
+
115
+ @staticmethod
116
+ def _validate_positive_id(value: int, name: str) -> None:
117
+ """Validate Procore integer identifiers."""
118
+ if value <= 0:
119
+ raise ValidationError(f"{name} must be a positive integer.")
120
+
121
+
122
+ def list_rfis(project_id: int, client: ProcoreClient | None = None) -> list[RFI]:
123
+ """Return RFIs for a Procore project."""
124
+ return RFIsService(client=client).list_rfis(project_id)
125
+
126
+
127
+ def get_rfi(
128
+ project_id: int,
129
+ rfi_id: int,
130
+ client: ProcoreClient | None = None,
131
+ ) -> RFI:
132
+ """Return a single RFI for a Procore project."""
133
+ return RFIsService(client=client).get_rfi(project_id, rfi_id)
134
+
135
+
136
+ def download_rfi_attachments(
137
+ project_id: int,
138
+ rfi_id: int,
139
+ destination_dir: Path | str | None = None,
140
+ client: ProcoreClient | None = None,
141
+ ) -> list[Path]:
142
+ """Download all attachments from an RFI's questions."""
143
+ return RFIsService(client=client).download_rfi_attachments(
144
+ project_id,
145
+ rfi_id,
146
+ destination_dir,
147
+ )