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 ADDED
@@ -0,0 +1,176 @@
1
+ """Command-line entrypoint for Procore SDK operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from pydantic import BaseModel
11
+
12
+ from core.config import get_settings
13
+ from services import (
14
+ download_rfi_attachments,
15
+ download_submittal_attachments,
16
+ get_rfi,
17
+ get_submittal,
18
+ list_companies,
19
+ list_projects,
20
+ list_rfis,
21
+ list_submittals,
22
+ )
23
+
24
+
25
+ def build_parser() -> argparse.ArgumentParser:
26
+ """Build the command-line parser."""
27
+ parser = argparse.ArgumentParser(description="Procore SDK utility commands")
28
+ subcommands = parser.add_subparsers(dest="command", required=True)
29
+
30
+ subcommands.add_parser("companies", help="List companies")
31
+
32
+ projects_parser = subcommands.add_parser("projects", help="List projects")
33
+ projects_parser.add_argument("--company-id", type=int, default=None)
34
+
35
+ rfis_parser = subcommands.add_parser("rfis", help="List RFIs for a project")
36
+ rfis_parser.add_argument(
37
+ "--project", "--project-id", dest="project_id", type=int, required=True
38
+ )
39
+
40
+ rfi_parser = subcommands.add_parser("rfi", help="Get one RFI")
41
+ rfi_parser.add_argument(
42
+ "--project", "--project-id", dest="project_id", type=int, required=True
43
+ )
44
+ rfi_parser.add_argument("--id", "--rfi-id", dest="rfi_id", type=int, required=True)
45
+
46
+ rfi_download_parser = _add_alias_parser(
47
+ subcommands,
48
+ "download-rfi",
49
+ ["download-rfi-attachments"],
50
+ "download-rfi-attachments",
51
+ help="Download RFI attachments",
52
+ )
53
+ rfi_download_parser.add_argument(
54
+ "--project", "--project-id", dest="project_id", type=int, required=True
55
+ )
56
+ rfi_download_parser.add_argument(
57
+ "--id", "--rfi-id", dest="rfi_id", type=int, required=True
58
+ )
59
+ rfi_download_parser.add_argument("--destination-dir", type=Path, default=None)
60
+
61
+ submittals_parser = subcommands.add_parser(
62
+ "submittals",
63
+ help="List submittals for a project",
64
+ )
65
+ submittals_parser.add_argument(
66
+ "--project", "--project-id", dest="project_id", type=int, required=True
67
+ )
68
+
69
+ submittal_parser = subcommands.add_parser("submittal", help="Get one submittal")
70
+ submittal_parser.add_argument(
71
+ "--project", "--project-id", dest="project_id", type=int, required=True
72
+ )
73
+ submittal_parser.add_argument(
74
+ "--id", "--submittal-id", dest="submittal_id", type=int, required=True
75
+ )
76
+
77
+ submittal_download_parser = _add_alias_parser(
78
+ subcommands,
79
+ "download-submittal",
80
+ ["download-submittal-attachments"],
81
+ "download-submittal-attachments",
82
+ help="Download submittal attachments",
83
+ )
84
+ submittal_download_parser.add_argument(
85
+ "--project", "--project-id", dest="project_id", type=int, required=True
86
+ )
87
+ submittal_download_parser.add_argument(
88
+ "--id", "--submittal-id", dest="submittal_id", type=int, required=True
89
+ )
90
+ submittal_download_parser.add_argument("--destination-dir", type=Path, default=None)
91
+
92
+ return parser
93
+
94
+
95
+ def _add_alias_parser(
96
+ subcommands: argparse._SubParsersAction[argparse.ArgumentParser],
97
+ name: str,
98
+ aliases: list[str],
99
+ legacy_name: str,
100
+ **kwargs: Any,
101
+ ) -> argparse.ArgumentParser:
102
+ """Add a subcommand with aliases when supported by argparse."""
103
+ parser = subcommands.add_parser(name, aliases=aliases, **kwargs)
104
+ parser.set_defaults(command=name, legacy_command=legacy_name)
105
+ return parser
106
+
107
+
108
+ def run_command(args: argparse.Namespace) -> Any:
109
+ """Run a parsed CLI command and return serializable output."""
110
+ if args.command == "companies":
111
+ return list_companies()
112
+
113
+ if args.command == "projects":
114
+ company_id = args.company_id or get_settings().company_id
115
+ return list_projects(company_id)
116
+
117
+ if args.command == "rfis":
118
+ return list_rfis(args.project_id)
119
+
120
+ if args.command == "rfi":
121
+ return get_rfi(args.project_id, args.rfi_id)
122
+
123
+ if args.command == "download-rfi":
124
+ return [
125
+ str(path)
126
+ for path in download_rfi_attachments(
127
+ args.project_id,
128
+ args.rfi_id,
129
+ args.destination_dir,
130
+ )
131
+ ]
132
+
133
+ if args.command == "submittals":
134
+ return list_submittals(args.project_id)
135
+
136
+ if args.command == "submittal":
137
+ return get_submittal(args.project_id, args.submittal_id)
138
+
139
+ if args.command == "download-submittal":
140
+ return [
141
+ str(path)
142
+ for path in download_submittal_attachments(
143
+ args.project_id,
144
+ args.submittal_id,
145
+ args.destination_dir,
146
+ )
147
+ ]
148
+
149
+ raise ValueError(f"Unsupported command: {args.command}")
150
+
151
+
152
+ def to_serializable(value: Any) -> Any:
153
+ """Convert SDK output into JSON-serializable data."""
154
+ if isinstance(value, BaseModel):
155
+ return value.model_dump(mode="json")
156
+ if isinstance(value, list):
157
+ return [to_serializable(item) for item in value]
158
+ if isinstance(value, tuple):
159
+ return [to_serializable(item) for item in value]
160
+ if isinstance(value, dict):
161
+ return {key: to_serializable(item) for key, item in value.items()}
162
+ if isinstance(value, Path):
163
+ return str(value)
164
+ return value
165
+
166
+
167
+ def main() -> None:
168
+ """Run the CLI entrypoint."""
169
+ parser = build_parser()
170
+ args = parser.parse_args()
171
+ result = run_command(args)
172
+ print(json.dumps(to_serializable(result), indent=2, default=str))
173
+
174
+
175
+ if __name__ == "__main__":
176
+ main()
auth/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """Authentication utilities for the Procore SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ __all__ = [
8
+ "OAuthClient",
9
+ "OAuthTokenResponse",
10
+ "StoredToken",
11
+ "TokenManager",
12
+ "TokenStore",
13
+ "get_access_token",
14
+ "load_token",
15
+ "save_token",
16
+ ]
17
+
18
+
19
+ def __getattr__(name: str) -> Any:
20
+ """Lazily expose auth objects without creating import cycles."""
21
+ if name in {"OAuthClient", "OAuthTokenResponse"}:
22
+ from auth.oauth import OAuthClient, OAuthTokenResponse
23
+
24
+ return {"OAuthClient": OAuthClient, "OAuthTokenResponse": OAuthTokenResponse}[
25
+ name
26
+ ]
27
+
28
+ if name in {"TokenManager", "get_access_token"}:
29
+ from auth.token_manager import TokenManager, get_access_token
30
+
31
+ return {"TokenManager": TokenManager, "get_access_token": get_access_token}[
32
+ name
33
+ ]
34
+
35
+ if name in {"StoredToken", "TokenStore", "load_token", "save_token"}:
36
+ from auth.token_store import StoredToken, TokenStore, load_token, save_token
37
+
38
+ return {
39
+ "StoredToken": StoredToken,
40
+ "TokenStore": TokenStore,
41
+ "load_token": load_token,
42
+ "save_token": save_token,
43
+ }[name]
44
+
45
+ raise AttributeError(f"module 'auth' has no attribute {name!r}")
auth/oauth.py ADDED
@@ -0,0 +1,166 @@
1
+ """OAuth helpers for authenticating with Procore.
2
+
3
+ The functions in this module exchange authorization codes and refresh tokens
4
+ for Procore OAuth access tokens. Configuration is loaded from ``core.config``;
5
+ callers never pass client secrets directly.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ import requests
13
+ from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
14
+
15
+ from core.config import ProcoreSettings, get_settings
16
+ from core.exceptions import AuthenticationError
17
+
18
+ TOKEN_ENDPOINT_PATH = "/oauth/token"
19
+ DEFAULT_TIMEOUT_SECONDS = 30
20
+
21
+
22
+ class OAuthTokenResponse(BaseModel):
23
+ """Validated OAuth token payload returned by Procore."""
24
+
25
+ access_token: SecretStr
26
+ token_type: str = Field(default="Bearer", min_length=1)
27
+ expires_in: int = Field(..., gt=0)
28
+ refresh_token: SecretStr | None = None
29
+ scope: str | None = None
30
+ created_at: int | None = None
31
+
32
+ model_config = ConfigDict(extra="allow")
33
+
34
+ @field_validator("token_type")
35
+ @classmethod
36
+ def _normalize_token_type(cls, value: str) -> str:
37
+ """Normalize token type values for downstream Authorization headers."""
38
+ return value.strip()
39
+
40
+
41
+ class OAuthClient:
42
+ """Client for Procore OAuth token operations."""
43
+
44
+ def __init__(
45
+ self,
46
+ settings: ProcoreSettings | None = None,
47
+ session: requests.Session | None = None,
48
+ timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
49
+ ) -> None:
50
+ """Initialize the OAuth client.
51
+
52
+ Args:
53
+ settings: Optional settings instance. Defaults to environment-backed
54
+ settings from ``get_settings()``.
55
+ session: Optional HTTP session. A new ``requests.Session`` is used
56
+ when omitted.
57
+ timeout_seconds: Request timeout for token calls.
58
+ """
59
+ self._settings = settings or get_settings()
60
+ self._session = session or requests.Session()
61
+ self._timeout_seconds = timeout_seconds
62
+
63
+ def exchange_authorization_code(
64
+ self, authorization_code: str
65
+ ) -> OAuthTokenResponse:
66
+ """Exchange an OAuth authorization code for access and refresh tokens.
67
+
68
+ Args:
69
+ authorization_code: The authorization code returned by Procore.
70
+
71
+ Returns:
72
+ A validated OAuth token response.
73
+
74
+ Raises:
75
+ AuthenticationError: If the exchange fails or the response is invalid.
76
+ """
77
+ code = authorization_code.strip()
78
+ if not code:
79
+ raise AuthenticationError("Authorization code is required.")
80
+
81
+ payload = {
82
+ "grant_type": "authorization_code",
83
+ "code": code,
84
+ "client_id": self._settings.client_id,
85
+ "client_secret": self._settings.client_secret.get_secret_value(),
86
+ "redirect_uri": self._settings.redirect_uri,
87
+ }
88
+ return self._request_token(payload)
89
+
90
+ def refresh_access_token(self, refresh_token: str) -> OAuthTokenResponse:
91
+ """Refresh an expired OAuth access token.
92
+
93
+ Args:
94
+ refresh_token: The current refresh token.
95
+
96
+ Returns:
97
+ A validated OAuth token response.
98
+
99
+ Raises:
100
+ AuthenticationError: If the refresh fails or the response is invalid.
101
+ """
102
+ token = refresh_token.strip()
103
+ if not token:
104
+ raise AuthenticationError("Refresh token is required.")
105
+
106
+ payload = {
107
+ "grant_type": "refresh_token",
108
+ "refresh_token": token,
109
+ "client_id": self._settings.client_id,
110
+ "client_secret": self._settings.client_secret.get_secret_value(),
111
+ }
112
+ return self._request_token(payload)
113
+
114
+ def _request_token(self, payload: dict[str, str]) -> OAuthTokenResponse:
115
+ """Send a token request and validate the response."""
116
+ token_url = f"{self._settings.login_url}{TOKEN_ENDPOINT_PATH}"
117
+
118
+ try:
119
+ response = self._session.post(
120
+ token_url,
121
+ data=payload,
122
+ headers={
123
+ "Accept": "application/json",
124
+ "Content-Type": "application/x-www-form-urlencoded",
125
+ },
126
+ timeout=self._timeout_seconds,
127
+ )
128
+ except requests.RequestException as exc:
129
+ raise AuthenticationError(f"OAuth token request failed: {exc}") from exc
130
+
131
+ if not response.ok:
132
+ raise AuthenticationError(self._format_error_response(response))
133
+
134
+ try:
135
+ response_data: dict[str, Any] = response.json()
136
+ except ValueError as exc:
137
+ raise AuthenticationError(
138
+ "OAuth token response was not valid JSON."
139
+ ) from exc
140
+
141
+ try:
142
+ return OAuthTokenResponse.model_validate(response_data)
143
+ except ValueError as exc:
144
+ raise AuthenticationError(
145
+ f"OAuth token response was invalid: {exc}"
146
+ ) from exc
147
+
148
+ @staticmethod
149
+ def _format_error_response(response: requests.Response) -> str:
150
+ """Build a safe error message without exposing credentials."""
151
+ try:
152
+ body = response.json()
153
+ except ValueError:
154
+ body = response.text
155
+
156
+ return f"OAuth token request failed with status {response.status_code}: {body}"
157
+
158
+
159
+ def exchange_authorization_code(authorization_code: str) -> OAuthTokenResponse:
160
+ """Exchange an authorization code using default environment configuration."""
161
+ return OAuthClient().exchange_authorization_code(authorization_code)
162
+
163
+
164
+ def refresh_access_token(refresh_token: str) -> OAuthTokenResponse:
165
+ """Refresh an access token using default environment configuration."""
166
+ return OAuthClient().refresh_access_token(refresh_token)
auth/token_manager.py ADDED
@@ -0,0 +1,106 @@
1
+ """High-level OAuth token management for the Procore SDK.
2
+
3
+ The token manager is the only authentication object the rest of the
4
+ application should need. It loads saved tokens, detects expiry, refreshes when
5
+ needed, and returns a plain access token string for HTTP clients.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from auth.oauth import OAuthClient, OAuthTokenResponse
11
+ from auth.token_store import StoredToken, TokenStore
12
+ from core.exceptions import AuthenticationError
13
+
14
+
15
+ class TokenManager:
16
+ """Manage loading, refreshing, and saving Procore OAuth tokens."""
17
+
18
+ def __init__(
19
+ self,
20
+ token_store: TokenStore | None = None,
21
+ oauth_client: OAuthClient | None = None,
22
+ ) -> None:
23
+ """Initialize the token manager.
24
+
25
+ Args:
26
+ token_store: Optional token persistence backend.
27
+ oauth_client: Optional OAuth client used for token refreshes.
28
+ """
29
+ self._token_store = token_store or TokenStore()
30
+ self._oauth_client = oauth_client or OAuthClient()
31
+
32
+ def get_access_token(self, force_refresh: bool = False) -> str:
33
+ """Return a valid access token, refreshing it when necessary.
34
+
35
+ Args:
36
+ force_refresh: Refresh the token even if the stored token has not
37
+ reached its refresh window.
38
+
39
+ Returns:
40
+ The current bearer access token as a plain string.
41
+
42
+ Raises:
43
+ AuthenticationError: If no token exists or refresh cannot complete.
44
+ """
45
+ token = self._token_store.load()
46
+ if token is None:
47
+ raise AuthenticationError(
48
+ "No Procore token is stored. Complete OAuth authorization first."
49
+ )
50
+
51
+ if force_refresh or token.is_expired():
52
+ token = self.refresh_token(token)
53
+
54
+ return token.access_token.get_secret_value()
55
+
56
+ def refresh_token(self, token: StoredToken | None = None) -> StoredToken:
57
+ """Refresh the stored access token and persist the replacement.
58
+
59
+ Args:
60
+ token: Optional token to refresh. Defaults to the currently stored
61
+ token.
62
+
63
+ Returns:
64
+ The refreshed stored token.
65
+
66
+ Raises:
67
+ AuthenticationError: If no refresh token is available.
68
+ """
69
+ current_token = token or self._token_store.load()
70
+ if current_token is None:
71
+ raise AuthenticationError("No Procore token is available to refresh.")
72
+
73
+ if current_token.refresh_token is None:
74
+ raise AuthenticationError("No Procore refresh token is available.")
75
+
76
+ refresh_token = current_token.refresh_token.get_secret_value()
77
+ refreshed_response = self._oauth_client.refresh_access_token(refresh_token)
78
+ refreshed_token = StoredToken.from_oauth_response(
79
+ refreshed_response,
80
+ existing_refresh_token=refresh_token,
81
+ )
82
+ self._token_store.save(refreshed_token)
83
+ return refreshed_token
84
+
85
+ def save_oauth_response(self, token_response: OAuthTokenResponse) -> StoredToken:
86
+ """Persist the token returned by an initial OAuth exchange.
87
+
88
+ Args:
89
+ token_response: OAuth response returned after exchanging an
90
+ authorization code.
91
+
92
+ Returns:
93
+ The stored token representation.
94
+ """
95
+ token = StoredToken.from_oauth_response(token_response)
96
+ self._token_store.save(token)
97
+ return token
98
+
99
+ def clear_token(self) -> None:
100
+ """Remove the saved token from the local token store."""
101
+ self._token_store.clear()
102
+
103
+
104
+ def get_access_token() -> str:
105
+ """Return a valid access token using the default token manager."""
106
+ return TokenManager().get_access_token()
auth/token_store.py ADDED
@@ -0,0 +1,158 @@
1
+ """Persistent storage for Procore OAuth tokens.
2
+
3
+ The token store owns file IO only. It validates token payloads, returns
4
+ ``None`` when no token has been saved yet, and writes updates atomically so a
5
+ partial process failure does not corrupt the token file.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
16
+
17
+ from core.exceptions import AuthenticationError
18
+
19
+ DEFAULT_EXPIRY_SKEW_SECONDS = 60
20
+ DEFAULT_TOKEN_FILE = Path(__file__).resolve().parent / "token_store.json"
21
+
22
+
23
+ class StoredToken(BaseModel):
24
+ """OAuth token data persisted by the SDK."""
25
+
26
+ access_token: SecretStr
27
+ expires_at: int = Field(..., gt=0)
28
+ refresh_token: SecretStr | None = None
29
+ token_type: str = Field(default="Bearer", min_length=1)
30
+ scope: str | None = None
31
+
32
+ model_config = ConfigDict(extra="allow")
33
+
34
+ @field_validator("token_type")
35
+ @classmethod
36
+ def _normalize_token_type(cls, value: str) -> str:
37
+ """Normalize token type values for Authorization headers."""
38
+ return value.strip()
39
+
40
+ @classmethod
41
+ def from_oauth_response(
42
+ cls,
43
+ token_response: Any,
44
+ existing_refresh_token: str | None = None,
45
+ ) -> "StoredToken":
46
+ """Create a stored token from an OAuth response model or mapping.
47
+
48
+ Args:
49
+ token_response: OAuth response returned by ``auth.oauth``.
50
+ existing_refresh_token: Refresh token to reuse if a refresh response
51
+ does not include a replacement.
52
+
53
+ Returns:
54
+ A validated token suitable for persistence.
55
+ """
56
+ if isinstance(token_response, BaseModel):
57
+ payload = token_response.model_dump(mode="python")
58
+ else:
59
+ payload = dict(token_response)
60
+
61
+ refresh_token = payload.get("refresh_token") or existing_refresh_token
62
+ expires_in = int(payload["expires_in"])
63
+
64
+ return cls(
65
+ access_token=payload["access_token"],
66
+ refresh_token=refresh_token,
67
+ token_type=payload.get("token_type", "Bearer"),
68
+ scope=payload.get("scope"),
69
+ expires_at=int(time.time()) + expires_in,
70
+ )
71
+
72
+ def is_expired(self, skew_seconds: int = DEFAULT_EXPIRY_SKEW_SECONDS) -> bool:
73
+ """Return whether the token is expired or inside the refresh window."""
74
+ return int(time.time()) >= self.expires_at - skew_seconds
75
+
76
+ def to_public_dict(self) -> dict[str, Any]:
77
+ """Serialize token values for local persistence."""
78
+ data = self.model_dump(mode="json")
79
+ data["access_token"] = self.access_token.get_secret_value()
80
+ if self.refresh_token is not None:
81
+ data["refresh_token"] = self.refresh_token.get_secret_value()
82
+ return data
83
+
84
+
85
+ class TokenStore:
86
+ """File-backed token persistence for the Procore SDK."""
87
+
88
+ def __init__(self, path: Path | str = DEFAULT_TOKEN_FILE) -> None:
89
+ """Initialize the store.
90
+
91
+ Args:
92
+ path: Token file path. Defaults to ``auth/token_store.json``.
93
+ """
94
+ self._path = Path(path)
95
+
96
+ @property
97
+ def path(self) -> Path:
98
+ """Return the token store file path."""
99
+ return self._path
100
+
101
+ def load(self) -> StoredToken | None:
102
+ """Load a saved token from disk.
103
+
104
+ Returns:
105
+ The saved token, or ``None`` when no token has been stored.
106
+
107
+ Raises:
108
+ AuthenticationError: If the token file exists but is unreadable or
109
+ malformed.
110
+ """
111
+ if not self._path.exists() or self._path.stat().st_size == 0:
112
+ return None
113
+
114
+ try:
115
+ raw = json.loads(self._path.read_text(encoding="utf-8"))
116
+ except OSError as exc:
117
+ raise AuthenticationError(f"Unable to read token store: {exc}") from exc
118
+ except json.JSONDecodeError as exc:
119
+ raise AuthenticationError("Token store contains invalid JSON.") from exc
120
+
121
+ if raw == {}:
122
+ return None
123
+
124
+ try:
125
+ return StoredToken.model_validate(raw)
126
+ except ValueError as exc:
127
+ raise AuthenticationError(
128
+ f"Token store contains invalid token data: {exc}"
129
+ ) from exc
130
+
131
+ def save(self, token: StoredToken) -> None:
132
+ """Persist a token to disk atomically."""
133
+ self._path.parent.mkdir(parents=True, exist_ok=True)
134
+ temporary_path = self._path.with_suffix(f"{self._path.suffix}.tmp")
135
+ payload = json.dumps(token.to_public_dict(), indent=2, sort_keys=True)
136
+
137
+ try:
138
+ temporary_path.write_text(f"{payload}\n", encoding="utf-8")
139
+ temporary_path.replace(self._path)
140
+ except OSError as exc:
141
+ raise AuthenticationError(f"Unable to save token store: {exc}") from exc
142
+
143
+ def clear(self) -> None:
144
+ """Delete the saved token file when it exists."""
145
+ try:
146
+ self._path.unlink(missing_ok=True)
147
+ except OSError as exc:
148
+ raise AuthenticationError(f"Unable to clear token store: {exc}") from exc
149
+
150
+
151
+ def load_token() -> StoredToken | None:
152
+ """Load the default stored token."""
153
+ return TokenStore().load()
154
+
155
+
156
+ def save_token(token: StoredToken) -> None:
157
+ """Save a token to the default token store."""
158
+ TokenStore().save(token)