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/logger.py ADDED
@@ -0,0 +1,141 @@
1
+ """Centralized structured logging utilities for the Procore SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import traceback
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ LOGGER_NAME = "procore_sdk"
13
+ LOG_DIR = Path(__file__).resolve().parents[1] / "logs"
14
+ SDK_LOG_FILE = LOG_DIR / "sdk.log"
15
+ ERROR_LOG_FILE = LOG_DIR / "errors.log"
16
+ DEFAULT_LOG_FORMAT = "%(asctime)s | %(levelname)s | %(name)s | %(message)s"
17
+
18
+ SENSITIVE_KEYS = {
19
+ "authorization",
20
+ "access_token",
21
+ "refresh_token",
22
+ "client_secret",
23
+ }
24
+
25
+
26
+ def ensure_log_directory() -> Path:
27
+ """Create and return the SDK log directory."""
28
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
29
+ return LOG_DIR
30
+
31
+
32
+ def get_logger(name: str | None = None) -> logging.Logger:
33
+ """Return a configured SDK logger.
34
+
35
+ Args:
36
+ name: Optional child logger name.
37
+
38
+ Returns:
39
+ A configured SDK ``logging.Logger`` instance.
40
+ """
41
+ ensure_log_directory()
42
+ logger_name = LOGGER_NAME if name is None else f"{LOGGER_NAME}.{name}"
43
+ logger = logging.getLogger(logger_name)
44
+
45
+ if not any(
46
+ getattr(handler, "_procore_sdk_handler", False) for handler in logger.handlers
47
+ ):
48
+ formatter = logging.Formatter(DEFAULT_LOG_FORMAT)
49
+
50
+ stream_handler = logging.StreamHandler()
51
+ stream_handler.setFormatter(formatter)
52
+ stream_handler._procore_sdk_handler = True # type: ignore[attr-defined]
53
+
54
+ sdk_file_handler = logging.FileHandler(SDK_LOG_FILE, encoding="utf-8")
55
+ sdk_file_handler.setFormatter(formatter)
56
+ sdk_file_handler._procore_sdk_handler = True # type: ignore[attr-defined]
57
+
58
+ error_file_handler = logging.FileHandler(ERROR_LOG_FILE, encoding="utf-8")
59
+ error_file_handler.setLevel(logging.ERROR)
60
+ error_file_handler.setFormatter(formatter)
61
+ error_file_handler._procore_sdk_handler = True # type: ignore[attr-defined]
62
+
63
+ logger.addHandler(stream_handler)
64
+ logger.addHandler(sdk_file_handler)
65
+ logger.addHandler(error_file_handler)
66
+
67
+ logger.setLevel(logging.INFO)
68
+ logger.propagate = False
69
+ return logger
70
+
71
+
72
+ def sanitize_log_value(value: Any) -> Any:
73
+ """Return a log-safe version of a value."""
74
+ if isinstance(value, dict):
75
+ return {
76
+ key: (
77
+ "[REDACTED]"
78
+ if key.lower() in SENSITIVE_KEYS
79
+ else sanitize_log_value(item)
80
+ )
81
+ for key, item in value.items()
82
+ }
83
+ if isinstance(value, list):
84
+ return [sanitize_log_value(item) for item in value]
85
+ return value
86
+
87
+
88
+ def structured_message(event: str, **fields: Any) -> str:
89
+ """Return a compact JSON log message."""
90
+ payload = {
91
+ "event": event,
92
+ "timestamp": datetime.now(tz=UTC).isoformat(),
93
+ **sanitize_log_value(fields),
94
+ }
95
+ return json.dumps(payload, default=str, sort_keys=True)
96
+
97
+
98
+ def log_api_request(
99
+ logger: logging.Logger,
100
+ *,
101
+ method: str,
102
+ endpoint: str,
103
+ status_code: int | None,
104
+ elapsed_ms: float,
105
+ retry_count: int = 0,
106
+ ) -> None:
107
+ """Log structured metadata for an API request."""
108
+ logger.info(
109
+ structured_message(
110
+ "api_request",
111
+ method=method,
112
+ endpoint=endpoint,
113
+ status_code=status_code,
114
+ elapsed_ms=round(elapsed_ms, 2),
115
+ retry_count=retry_count,
116
+ )
117
+ )
118
+
119
+
120
+ def log_exception(
121
+ logger: logging.Logger,
122
+ *,
123
+ exc: BaseException,
124
+ request_url: str | None = None,
125
+ http_status: int | None = None,
126
+ response_body: Any | None = None,
127
+ ) -> None:
128
+ """Log a structured exception record with stack trace."""
129
+ logger.error(
130
+ structured_message(
131
+ "exception",
132
+ exception_type=type(exc).__name__,
133
+ request_url=request_url,
134
+ http_status=http_status,
135
+ response_body=response_body,
136
+ stack_trace="".join(traceback.format_exception(exc)),
137
+ )
138
+ )
139
+
140
+
141
+ logger = get_logger()
models/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """Typed response models for Procore resources."""
2
+
3
+ from models.base import ProcoreModel
4
+ from models.resources import (
5
+ Attachment,
6
+ Company,
7
+ Project,
8
+ RFI,
9
+ RFIQuestion,
10
+ Status,
11
+ Submittal,
12
+ User,
13
+ )
14
+
15
+ __all__ = [
16
+ "Attachment",
17
+ "Company",
18
+ "ProcoreModel",
19
+ "Project",
20
+ "RFI",
21
+ "RFIQuestion",
22
+ "Status",
23
+ "Submittal",
24
+ "User",
25
+ ]
models/base.py ADDED
@@ -0,0 +1,11 @@
1
+ """Base model primitives for Procore response models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+
8
+ class ProcoreModel(BaseModel):
9
+ """Base model that preserves unknown Procore response fields."""
10
+
11
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
models/resources.py ADDED
@@ -0,0 +1,86 @@
1
+ """Typed Pydantic models for common Procore resources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import Field, HttpUrl
8
+
9
+ from models.base import ProcoreModel
10
+
11
+
12
+ class Status(ProcoreModel):
13
+ """Status metadata returned by Procore resources."""
14
+
15
+ id: int | None = None
16
+ name: str | None = None
17
+ status: str | None = None
18
+
19
+
20
+ class User(ProcoreModel):
21
+ """Procore user summary."""
22
+
23
+ id: int | None = None
24
+ name: str | None = None
25
+ login: str | None = None
26
+ email_address: str | None = None
27
+
28
+
29
+ class Attachment(ProcoreModel):
30
+ """Attachment metadata with a signed download URL."""
31
+
32
+ id: int | None = None
33
+ name: str | None = None
34
+ filename: str | None = None
35
+ file_name: str | None = None
36
+ url: str | HttpUrl | None = None
37
+ content_type: str | None = None
38
+
39
+
40
+ class Company(ProcoreModel):
41
+ """Procore company resource."""
42
+
43
+ id: int
44
+ name: str | None = None
45
+
46
+
47
+ class Project(ProcoreModel):
48
+ """Procore project resource."""
49
+
50
+ id: int
51
+ name: str | None = None
52
+ project_number: str | None = None
53
+ active: bool | None = None
54
+ company: Company | None = None
55
+
56
+
57
+ class RFIQuestion(ProcoreModel):
58
+ """Question nested under a Procore RFI."""
59
+
60
+ id: int | None = None
61
+ body: str | None = None
62
+ plain_text_body: str | None = None
63
+ attachments: list[Attachment] = Field(default_factory=list)
64
+
65
+
66
+ class RFI(ProcoreModel):
67
+ """Procore RFI resource."""
68
+
69
+ id: int
70
+ number: str | int | None = None
71
+ subject: str | None = None
72
+ status: Status | str | None = None
73
+ assignee: User | None = None
74
+ created_by: User | None = None
75
+ questions: list[RFIQuestion] = Field(default_factory=list)
76
+
77
+
78
+ class Submittal(ProcoreModel):
79
+ """Procore submittal resource."""
80
+
81
+ id: int
82
+ number: str | int | None = None
83
+ title: str | None = None
84
+ status: Status | str | None = None
85
+ responsible_contractor: dict[str, Any] | None = None
86
+ attachments: list[Attachment] = Field(default_factory=list)
parser/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """Parser utilities for Procore automation workflows."""
2
+
3
+ from parser.email_parser import (
4
+ EmailParser,
5
+ ParsedEmail,
6
+ ParsedEmailAttachment,
7
+ parse_email_file,
8
+ parse_email_text,
9
+ )
10
+
11
+ __all__ = [
12
+ "EmailParser",
13
+ "ParsedEmail",
14
+ "ParsedEmailAttachment",
15
+ "parse_email_file",
16
+ "parse_email_text",
17
+ ]
parser/email_parser.py ADDED
@@ -0,0 +1,165 @@
1
+ """Email parsing utilities for future Procore automation workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from email import policy
6
+ from email.message import EmailMessage, Message
7
+ from email.parser import BytesParser, Parser
8
+ from pathlib import Path
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+
13
+ class ParsedEmailAttachment(BaseModel):
14
+ """Metadata for an attachment found in an email message."""
15
+
16
+ filename: str
17
+ content_type: str
18
+ size_bytes: int = Field(ge=0)
19
+
20
+
21
+ class ParsedEmail(BaseModel):
22
+ """Structured representation of an email message."""
23
+
24
+ subject: str | None = None
25
+ sender: str | None = None
26
+ recipients: list[str] = Field(default_factory=list)
27
+ cc: list[str] = Field(default_factory=list)
28
+ date: str | None = None
29
+ text_body: str | None = None
30
+ html_body: str | None = None
31
+ attachments: list[ParsedEmailAttachment] = Field(default_factory=list)
32
+
33
+
34
+ class EmailParser:
35
+ """Parse raw email messages into structured data."""
36
+
37
+ def parse_file(self, path: Path | str) -> ParsedEmail:
38
+ """Parse an email message from a file path.
39
+
40
+ Args:
41
+ path: Path to a raw ``.eml`` style email file.
42
+
43
+ Returns:
44
+ Parsed email data.
45
+ """
46
+ message_path = Path(path)
47
+ with message_path.open("rb") as file_handle:
48
+ message = BytesParser(policy=policy.default).parse(file_handle)
49
+ return self.parse_message(message)
50
+
51
+ def parse_text(self, raw_email: str) -> ParsedEmail:
52
+ """Parse an email message from raw text."""
53
+ message = Parser(policy=policy.default).parsestr(raw_email)
54
+ return self.parse_message(message)
55
+
56
+ def parse_bytes(self, raw_email: bytes) -> ParsedEmail:
57
+ """Parse an email message from raw bytes."""
58
+ message = BytesParser(policy=policy.default).parsebytes(raw_email)
59
+ return self.parse_message(message)
60
+
61
+ def parse_message(self, message: Message) -> ParsedEmail:
62
+ """Parse an ``email.message.Message`` object."""
63
+ text_body, html_body = self._extract_bodies(message)
64
+ return ParsedEmail(
65
+ subject=self._header_value(message, "subject"),
66
+ sender=self._header_value(message, "from"),
67
+ recipients=self._split_addresses(message.get_all("to", [])),
68
+ cc=self._split_addresses(message.get_all("cc", [])),
69
+ date=self._header_value(message, "date"),
70
+ text_body=text_body,
71
+ html_body=html_body,
72
+ attachments=self._extract_attachments(message),
73
+ )
74
+
75
+ @staticmethod
76
+ def _extract_bodies(message: Message) -> tuple[str | None, str | None]:
77
+ """Extract plain text and HTML body content from a message."""
78
+ text_body: str | None = None
79
+ html_body: str | None = None
80
+
81
+ if message.is_multipart():
82
+ for part in message.walk():
83
+ if (
84
+ part.is_multipart()
85
+ or part.get_content_disposition() == "attachment"
86
+ ):
87
+ continue
88
+
89
+ content_type = part.get_content_type()
90
+ payload = EmailParser._part_text(part)
91
+ if content_type == "text/plain" and text_body is None:
92
+ text_body = payload
93
+ elif content_type == "text/html" and html_body is None:
94
+ html_body = payload
95
+ else:
96
+ payload = EmailParser._part_text(message)
97
+ if message.get_content_type() == "text/html":
98
+ html_body = payload
99
+ else:
100
+ text_body = payload
101
+
102
+ return text_body, html_body
103
+
104
+ @staticmethod
105
+ def _extract_attachments(message: Message) -> list[ParsedEmailAttachment]:
106
+ """Return attachment metadata without writing files to disk."""
107
+ attachments: list[ParsedEmailAttachment] = []
108
+ for part in message.walk():
109
+ if part.get_content_disposition() != "attachment":
110
+ continue
111
+
112
+ filename = part.get_filename()
113
+ if not filename:
114
+ filename = "attachment"
115
+
116
+ payload = part.get_payload(decode=True) or b""
117
+ attachments.append(
118
+ ParsedEmailAttachment(
119
+ filename=Path(filename).name,
120
+ content_type=part.get_content_type(),
121
+ size_bytes=len(payload),
122
+ )
123
+ )
124
+
125
+ return attachments
126
+
127
+ @staticmethod
128
+ def _part_text(part: Message) -> str | None:
129
+ """Decode a message part into text when possible."""
130
+ if hasattr(part, "get_content"):
131
+ content = part.get_content()
132
+ return content if isinstance(content, str) else None
133
+
134
+ payload = part.get_payload(decode=True)
135
+ if payload is None:
136
+ return None
137
+
138
+ charset = part.get_content_charset() or "utf-8"
139
+ return payload.decode(charset, errors="replace")
140
+
141
+ @staticmethod
142
+ def _header_value(message: Message, name: str) -> str | None:
143
+ """Return a header as a string when present."""
144
+ value = message.get(name)
145
+ return str(value) if value is not None else None
146
+
147
+ @staticmethod
148
+ def _split_addresses(values: list[str]) -> list[str]:
149
+ """Split comma-separated address headers into normalized strings."""
150
+ addresses: list[str] = []
151
+ for value in values:
152
+ addresses.extend(
153
+ part.strip() for part in str(value).split(",") if part.strip()
154
+ )
155
+ return addresses
156
+
157
+
158
+ def parse_email_file(path: Path | str) -> ParsedEmail:
159
+ """Parse an email message from a file path."""
160
+ return EmailParser().parse_file(path)
161
+
162
+
163
+ def parse_email_text(raw_email: str) -> ParsedEmail:
164
+ """Parse an email message from raw text."""
165
+ return EmailParser().parse_text(raw_email)
@@ -0,0 +1,241 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyprocore
3
+ Version: 1.0.0
4
+ Summary: Production-ready Python SDK foundation for the Procore REST API.
5
+ Author-email: Author Placeholder <author@example.com>
6
+ License: Proprietary - placeholder
7
+ Requires-Python: >=3.12
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: requests>=2.32.3
10
+ Requires-Dist: python-dotenv>=1.0.1
11
+ Requires-Dist: pydantic>=2.8.2
12
+ Requires-Dist: tenacity>=8.5.0
13
+ Provides-Extra: dev
14
+ Requires-Dist: black>=24.10.0; extra == "dev"
15
+ Requires-Dist: coverage>=7.6.0; extra == "dev"
16
+ Requires-Dist: flake8>=7.1.0; extra == "dev"
17
+ Requires-Dist: isort>=5.13.2; extra == "dev"
18
+ Requires-Dist: mypy>=1.13.0; extra == "dev"
19
+ Requires-Dist: types-requests>=2.32.0; extra == "dev"
20
+
21
+ # Procore SDK
22
+
23
+ Production-quality Python SDK and automation foundation for the Procore REST API.
24
+
25
+ The SDK handles configuration, OAuth token refresh, authenticated HTTP requests,
26
+ pagination, typed response models, structured logging, and attachment downloads
27
+ for companies, projects, RFIs, and submittals.
28
+
29
+ ## Installation
30
+
31
+ Requires Python 3.12+.
32
+
33
+ ```bash
34
+ python3 -m venv .venv
35
+ .venv/bin/python -m pip install --upgrade pip
36
+ .venv/bin/python -m pip install -r requirements.txt
37
+ ```
38
+
39
+ ## Configuration
40
+
41
+ Copy the example file and fill in real values:
42
+
43
+ ```bash
44
+ cp .env.example .env
45
+ ```
46
+
47
+ Required variables:
48
+
49
+ ```bash
50
+ PROCORE_CLIENT_ID=your_client_id
51
+ PROCORE_CLIENT_SECRET=your_client_secret
52
+ PROCORE_REDIRECT_URI=http://localhost:8080/callback
53
+ PROCORE_LOGIN_URL=https://login.procore.com
54
+ PROCORE_API_BASE=https://api.procore.com
55
+ PROCORE_COMPANY_ID=123456
56
+ ```
57
+
58
+ Secrets, tokens, URLs, and company IDs are never hardcoded in source.
59
+
60
+ ## Authentication
61
+
62
+ Exchange the first authorization code and save the token locally:
63
+
64
+ ```python
65
+ from auth.oauth import exchange_authorization_code
66
+ from auth.token_manager import TokenManager
67
+
68
+ token_response = exchange_authorization_code("authorization-code-from-procore")
69
+ TokenManager().save_oauth_response(token_response)
70
+ ```
71
+
72
+ After that, SDK clients call:
73
+
74
+ ```python
75
+ from auth.token_manager import get_access_token
76
+
77
+ access_token = get_access_token()
78
+ ```
79
+
80
+ Expired access tokens refresh automatically when a refresh token is available.
81
+
82
+ ## CLI Examples
83
+
84
+ ```bash
85
+ .venv/bin/python app.py companies
86
+ .venv/bin/python app.py projects
87
+ .venv/bin/python app.py rfis --project 352338
88
+ .venv/bin/python app.py rfi --project 352338 --id 102784
89
+ .venv/bin/python app.py submittals --project 352338
90
+ .venv/bin/python app.py submittal --project 352338 --id 309641
91
+ .venv/bin/python app.py download-rfi --project 352338 --id 102784
92
+ .venv/bin/python app.py download-submittal --project 352338 --id 309641
93
+ ```
94
+
95
+ The CLI prints nicely formatted JSON. Typed SDK models are serialized with
96
+ `model_dump(mode="json")`.
97
+
98
+ ## SDK Examples
99
+
100
+ ```python
101
+ from services import (
102
+ download_rfi_attachments,
103
+ download_submittal_attachments,
104
+ get_rfi,
105
+ get_submittal,
106
+ list_companies,
107
+ list_projects,
108
+ list_rfis,
109
+ list_submittals,
110
+ )
111
+
112
+ companies = list_companies()
113
+ projects = list_projects(company_id=123456)
114
+
115
+ rfis = list_rfis(project_id=352338)
116
+ rfi = get_rfi(project_id=352338, rfi_id=102784)
117
+ first_attachment_url = rfi.questions[0].attachments[0].url
118
+
119
+ submittals = list_submittals(project_id=352338)
120
+ submittal = get_submittal(project_id=352338, submittal_id=309641)
121
+ ```
122
+
123
+ All typed models can be serialized back to JSON:
124
+
125
+ ```python
126
+ json_payload = rfi.model_dump(mode="json")
127
+ json_string = rfi.model_dump_json()
128
+ ```
129
+
130
+ ## Downloading Attachments
131
+
132
+ RFI attachments are read from:
133
+
134
+ ```text
135
+ questions[].attachments[].url
136
+ ```
137
+
138
+ Submittal attachments are read from:
139
+
140
+ ```text
141
+ attachments[].url
142
+ ```
143
+
144
+ Download from services:
145
+
146
+ ```python
147
+ rfi_files = download_rfi_attachments(project_id=352338, rfi_id=102784)
148
+ submittal_files = download_submittal_attachments(
149
+ project_id=352338,
150
+ submittal_id=309641,
151
+ )
152
+ ```
153
+
154
+ The shared file service supports safe filenames, streaming writes, retries,
155
+ progress logging, batch downloads, and skip-existing behavior by default.
156
+
157
+ ```python
158
+ from services.files import FileDownloadService
159
+
160
+ files = FileDownloadService().download_attachments(
161
+ attachments,
162
+ "downloads/custom",
163
+ fallback_prefix="attachment",
164
+ overwrite=False,
165
+ )
166
+ ```
167
+
168
+ ## Pagination
169
+
170
+ Collection service methods use `ProcoreClient.get_all()`, which follows Procore
171
+ pagination headers automatically. Business logic should call the service method
172
+ or `get_all()` and should not manually request page 2.
173
+
174
+ ## Logging
175
+
176
+ The SDK writes structured logs to:
177
+
178
+ ```text
179
+ logs/sdk.log
180
+ logs/errors.log
181
+ ```
182
+
183
+ API request logs include method, endpoint, response status, elapsed time, and
184
+ retry count. Exception logs include stack traces, exception type, request URL,
185
+ HTTP status, and response body when available.
186
+
187
+ The logger redacts sensitive keys such as authorization headers, access tokens,
188
+ refresh tokens, and client secrets.
189
+
190
+ ## Architecture
191
+
192
+ - `auth/`: OAuth exchange, token persistence, token refresh
193
+ - `core/`: configuration, endpoint paths, HTTP client, logging, exceptions
194
+ - `models/`: Pydantic response models
195
+ - `services/`: company, project, RFI, submittal, and file services
196
+ - `parser/`: email parsing utilities for future automation
197
+ - `tests/`: mocked unit tests with no live Procore dependency
198
+
199
+ ## Verified Endpoint Assumptions
200
+
201
+ - `GET /rest/v1.0/companies`
202
+ - `GET /rest/v1.0/companies/{company_id}/projects`
203
+ - `GET /rest/v1.1/projects/{project_id}/rfis`
204
+ - `GET /rest/v1.1/projects/{project_id}/rfis/{rfi_id}`
205
+ - `GET /rest/v1.1/projects/{project_id}/submittals`
206
+ - `GET /rest/v1.1/projects/{project_id}/submittals/{submittal_id}`
207
+
208
+ ## Troubleshooting
209
+
210
+ `ConfigurationError`
211
+ : Check that `.env` exists and all required keys are present.
212
+
213
+ `AuthenticationError`
214
+ : Complete the first OAuth code exchange and confirm `auth/token_store.json`
215
+ contains a refresh token.
216
+
217
+ `AuthorizationError`
218
+ : Confirm the Procore user has access to the target company/project/resource.
219
+
220
+ `ResourceNotFoundError`
221
+ : Confirm project, RFI, or submittal IDs are correct for the configured company.
222
+
223
+ Attachment files are not downloading
224
+ : Check `logs/errors.log` for HTTP status and response body details. Existing
225
+ files are skipped unless `overwrite=True`.
226
+
227
+ ## Tests
228
+
229
+ Run unit tests:
230
+
231
+ ```bash
232
+ .venv/bin/python -m unittest discover -s tests
233
+ ```
234
+
235
+ Run coverage:
236
+
237
+ ```bash
238
+ .venv/bin/python -m coverage run -m unittest discover -s tests
239
+ .venv/bin/python -m coverage report
240
+ ```
241
+