pyprocore 1.0.0__tar.gz

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.
Files changed (37) hide show
  1. pyprocore-1.0.0/PKG-INFO +241 -0
  2. pyprocore-1.0.0/README.md +221 -0
  3. pyprocore-1.0.0/app.py +176 -0
  4. pyprocore-1.0.0/auth/__init__.py +45 -0
  5. pyprocore-1.0.0/auth/oauth.py +166 -0
  6. pyprocore-1.0.0/auth/token_manager.py +106 -0
  7. pyprocore-1.0.0/auth/token_store.py +158 -0
  8. pyprocore-1.0.0/core/__init__.py +57 -0
  9. pyprocore-1.0.0/core/client.py +425 -0
  10. pyprocore-1.0.0/core/config.py +97 -0
  11. pyprocore-1.0.0/core/endpoints.py +54 -0
  12. pyprocore-1.0.0/core/exceptions.py +58 -0
  13. pyprocore-1.0.0/core/logger.py +141 -0
  14. pyprocore-1.0.0/models/__init__.py +25 -0
  15. pyprocore-1.0.0/models/base.py +11 -0
  16. pyprocore-1.0.0/models/resources.py +86 -0
  17. pyprocore-1.0.0/parser/__init__.py +17 -0
  18. pyprocore-1.0.0/parser/email_parser.py +165 -0
  19. pyprocore-1.0.0/pyprocore.egg-info/PKG-INFO +241 -0
  20. pyprocore-1.0.0/pyprocore.egg-info/SOURCES.txt +35 -0
  21. pyprocore-1.0.0/pyprocore.egg-info/dependency_links.txt +1 -0
  22. pyprocore-1.0.0/pyprocore.egg-info/entry_points.txt +2 -0
  23. pyprocore-1.0.0/pyprocore.egg-info/requires.txt +12 -0
  24. pyprocore-1.0.0/pyprocore.egg-info/top_level.txt +6 -0
  25. pyprocore-1.0.0/pyproject.toml +62 -0
  26. pyprocore-1.0.0/services/__init__.py +31 -0
  27. pyprocore-1.0.0/services/companies.py +31 -0
  28. pyprocore-1.0.0/services/files.py +281 -0
  29. pyprocore-1.0.0/services/projects.py +87 -0
  30. pyprocore-1.0.0/services/rfis.py +147 -0
  31. pyprocore-1.0.0/services/submittals.py +140 -0
  32. pyprocore-1.0.0/setup.cfg +4 -0
  33. pyprocore-1.0.0/tests/test_client.py +250 -0
  34. pyprocore-1.0.0/tests/test_endpoints.py +45 -0
  35. pyprocore-1.0.0/tests/test_files.py +227 -0
  36. pyprocore-1.0.0/tests/test_services.py +260 -0
  37. pyprocore-1.0.0/tests/test_token_management.py +217 -0
@@ -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
+
@@ -0,0 +1,221 @@
1
+ # Procore SDK
2
+
3
+ Production-quality Python SDK and automation foundation for the Procore REST API.
4
+
5
+ The SDK handles configuration, OAuth token refresh, authenticated HTTP requests,
6
+ pagination, typed response models, structured logging, and attachment downloads
7
+ for companies, projects, RFIs, and submittals.
8
+
9
+ ## Installation
10
+
11
+ Requires Python 3.12+.
12
+
13
+ ```bash
14
+ python3 -m venv .venv
15
+ .venv/bin/python -m pip install --upgrade pip
16
+ .venv/bin/python -m pip install -r requirements.txt
17
+ ```
18
+
19
+ ## Configuration
20
+
21
+ Copy the example file and fill in real values:
22
+
23
+ ```bash
24
+ cp .env.example .env
25
+ ```
26
+
27
+ Required variables:
28
+
29
+ ```bash
30
+ PROCORE_CLIENT_ID=your_client_id
31
+ PROCORE_CLIENT_SECRET=your_client_secret
32
+ PROCORE_REDIRECT_URI=http://localhost:8080/callback
33
+ PROCORE_LOGIN_URL=https://login.procore.com
34
+ PROCORE_API_BASE=https://api.procore.com
35
+ PROCORE_COMPANY_ID=123456
36
+ ```
37
+
38
+ Secrets, tokens, URLs, and company IDs are never hardcoded in source.
39
+
40
+ ## Authentication
41
+
42
+ Exchange the first authorization code and save the token locally:
43
+
44
+ ```python
45
+ from auth.oauth import exchange_authorization_code
46
+ from auth.token_manager import TokenManager
47
+
48
+ token_response = exchange_authorization_code("authorization-code-from-procore")
49
+ TokenManager().save_oauth_response(token_response)
50
+ ```
51
+
52
+ After that, SDK clients call:
53
+
54
+ ```python
55
+ from auth.token_manager import get_access_token
56
+
57
+ access_token = get_access_token()
58
+ ```
59
+
60
+ Expired access tokens refresh automatically when a refresh token is available.
61
+
62
+ ## CLI Examples
63
+
64
+ ```bash
65
+ .venv/bin/python app.py companies
66
+ .venv/bin/python app.py projects
67
+ .venv/bin/python app.py rfis --project 352338
68
+ .venv/bin/python app.py rfi --project 352338 --id 102784
69
+ .venv/bin/python app.py submittals --project 352338
70
+ .venv/bin/python app.py submittal --project 352338 --id 309641
71
+ .venv/bin/python app.py download-rfi --project 352338 --id 102784
72
+ .venv/bin/python app.py download-submittal --project 352338 --id 309641
73
+ ```
74
+
75
+ The CLI prints nicely formatted JSON. Typed SDK models are serialized with
76
+ `model_dump(mode="json")`.
77
+
78
+ ## SDK Examples
79
+
80
+ ```python
81
+ from services import (
82
+ download_rfi_attachments,
83
+ download_submittal_attachments,
84
+ get_rfi,
85
+ get_submittal,
86
+ list_companies,
87
+ list_projects,
88
+ list_rfis,
89
+ list_submittals,
90
+ )
91
+
92
+ companies = list_companies()
93
+ projects = list_projects(company_id=123456)
94
+
95
+ rfis = list_rfis(project_id=352338)
96
+ rfi = get_rfi(project_id=352338, rfi_id=102784)
97
+ first_attachment_url = rfi.questions[0].attachments[0].url
98
+
99
+ submittals = list_submittals(project_id=352338)
100
+ submittal = get_submittal(project_id=352338, submittal_id=309641)
101
+ ```
102
+
103
+ All typed models can be serialized back to JSON:
104
+
105
+ ```python
106
+ json_payload = rfi.model_dump(mode="json")
107
+ json_string = rfi.model_dump_json()
108
+ ```
109
+
110
+ ## Downloading Attachments
111
+
112
+ RFI attachments are read from:
113
+
114
+ ```text
115
+ questions[].attachments[].url
116
+ ```
117
+
118
+ Submittal attachments are read from:
119
+
120
+ ```text
121
+ attachments[].url
122
+ ```
123
+
124
+ Download from services:
125
+
126
+ ```python
127
+ rfi_files = download_rfi_attachments(project_id=352338, rfi_id=102784)
128
+ submittal_files = download_submittal_attachments(
129
+ project_id=352338,
130
+ submittal_id=309641,
131
+ )
132
+ ```
133
+
134
+ The shared file service supports safe filenames, streaming writes, retries,
135
+ progress logging, batch downloads, and skip-existing behavior by default.
136
+
137
+ ```python
138
+ from services.files import FileDownloadService
139
+
140
+ files = FileDownloadService().download_attachments(
141
+ attachments,
142
+ "downloads/custom",
143
+ fallback_prefix="attachment",
144
+ overwrite=False,
145
+ )
146
+ ```
147
+
148
+ ## Pagination
149
+
150
+ Collection service methods use `ProcoreClient.get_all()`, which follows Procore
151
+ pagination headers automatically. Business logic should call the service method
152
+ or `get_all()` and should not manually request page 2.
153
+
154
+ ## Logging
155
+
156
+ The SDK writes structured logs to:
157
+
158
+ ```text
159
+ logs/sdk.log
160
+ logs/errors.log
161
+ ```
162
+
163
+ API request logs include method, endpoint, response status, elapsed time, and
164
+ retry count. Exception logs include stack traces, exception type, request URL,
165
+ HTTP status, and response body when available.
166
+
167
+ The logger redacts sensitive keys such as authorization headers, access tokens,
168
+ refresh tokens, and client secrets.
169
+
170
+ ## Architecture
171
+
172
+ - `auth/`: OAuth exchange, token persistence, token refresh
173
+ - `core/`: configuration, endpoint paths, HTTP client, logging, exceptions
174
+ - `models/`: Pydantic response models
175
+ - `services/`: company, project, RFI, submittal, and file services
176
+ - `parser/`: email parsing utilities for future automation
177
+ - `tests/`: mocked unit tests with no live Procore dependency
178
+
179
+ ## Verified Endpoint Assumptions
180
+
181
+ - `GET /rest/v1.0/companies`
182
+ - `GET /rest/v1.0/companies/{company_id}/projects`
183
+ - `GET /rest/v1.1/projects/{project_id}/rfis`
184
+ - `GET /rest/v1.1/projects/{project_id}/rfis/{rfi_id}`
185
+ - `GET /rest/v1.1/projects/{project_id}/submittals`
186
+ - `GET /rest/v1.1/projects/{project_id}/submittals/{submittal_id}`
187
+
188
+ ## Troubleshooting
189
+
190
+ `ConfigurationError`
191
+ : Check that `.env` exists and all required keys are present.
192
+
193
+ `AuthenticationError`
194
+ : Complete the first OAuth code exchange and confirm `auth/token_store.json`
195
+ contains a refresh token.
196
+
197
+ `AuthorizationError`
198
+ : Confirm the Procore user has access to the target company/project/resource.
199
+
200
+ `ResourceNotFoundError`
201
+ : Confirm project, RFI, or submittal IDs are correct for the configured company.
202
+
203
+ Attachment files are not downloading
204
+ : Check `logs/errors.log` for HTTP status and response body details. Existing
205
+ files are skipped unless `overwrite=True`.
206
+
207
+ ## Tests
208
+
209
+ Run unit tests:
210
+
211
+ ```bash
212
+ .venv/bin/python -m unittest discover -s tests
213
+ ```
214
+
215
+ Run coverage:
216
+
217
+ ```bash
218
+ .venv/bin/python -m coverage run -m unittest discover -s tests
219
+ .venv/bin/python -m coverage report
220
+ ```
221
+
pyprocore-1.0.0/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()
@@ -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}")