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/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
|
+
|