hpde-analytics-cli 1.0.0__tar.gz → 6.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 (33) hide show
  1. {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/PKG-INFO +9 -5
  2. {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/README.md +2 -2
  3. {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli/api/client.py +192 -91
  4. {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli/auth/oauth.py +14 -15
  5. hpde_analytics_cli-6.0.0/hpde_analytics_cli/integrations/__init__.py +1 -0
  6. hpde_analytics_cli-6.0.0/hpde_analytics_cli/integrations/email_populator.py +247 -0
  7. hpde_analytics_cli-6.0.0/hpde_analytics_cli/integrations/google_sheets.py +150 -0
  8. {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli/main.py +137 -5
  9. hpde_analytics_cli-6.0.0/hpde_analytics_cli/utils/data_export.py +394 -0
  10. {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli/utils/field_discovery.py +26 -23
  11. {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli/utils/report_generator.py +262 -222
  12. {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli.egg-info/PKG-INFO +9 -5
  13. {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli.egg-info/SOURCES.txt +5 -0
  14. {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli.egg-info/requires.txt +3 -0
  15. {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/pyproject.toml +8 -4
  16. hpde_analytics_cli-6.0.0/tests/test_email_populator.py +388 -0
  17. hpde_analytics_cli-6.0.0/tests/test_google_sheets.py +225 -0
  18. {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/tests/test_main.py +100 -0
  19. hpde_analytics_cli-1.0.0/hpde_analytics_cli/utils/data_export.py +0 -366
  20. {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/LICENSE +0 -0
  21. {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli/__init__.py +0 -0
  22. {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli/api/__init__.py +0 -0
  23. {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli/auth/__init__.py +0 -0
  24. {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli/auth/credentials.py +0 -0
  25. {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli/utils/__init__.py +0 -0
  26. {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli.egg-info/dependency_links.txt +0 -0
  27. {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli.egg-info/entry_points.txt +0 -0
  28. {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/hpde_analytics_cli.egg-info/top_level.txt +0 -0
  29. {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/setup.cfg +0 -0
  30. {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/tests/test_client.py +0 -0
  31. {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/tests/test_credentials.py +0 -0
  32. {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/tests/test_data_export.py +0 -0
  33. {hpde_analytics_cli-1.0.0 → hpde_analytics_cli-6.0.0}/tests/test_report_generator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hpde-analytics-cli
3
- Version: 1.0.0
3
+ Version: 6.0.0
4
4
  Summary: Data analysis and reporting tool for HPDE and Time Trials programs
5
5
  Author-email: Kevin Homan <homan13@gmail.com>
6
6
  License: MIT
@@ -15,13 +15,14 @@ Classifier: Intended Audience :: End Users/Desktop
15
15
  Classifier: License :: OSI Approved :: MIT License
16
16
  Classifier: Operating System :: OS Independent
17
17
  Classifier: Programming Language :: Python :: 3
18
- Classifier: Programming Language :: Python :: 3.8
19
18
  Classifier: Programming Language :: Python :: 3.9
20
19
  Classifier: Programming Language :: Python :: 3.10
21
20
  Classifier: Programming Language :: Python :: 3.11
22
21
  Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
23
24
  Classifier: Topic :: Utilities
24
- Requires-Python: >=3.8
25
+ Requires-Python: >=3.9
25
26
  Description-Content-Type: text/markdown
26
27
  License-File: LICENSE
27
28
  Requires-Dist: requests>=2.28.0
@@ -29,6 +30,8 @@ Requires-Dist: requests-oauthlib>=1.3.0
29
30
  Requires-Dist: python-dotenv>=1.0.0
30
31
  Requires-Dist: openpyxl>=3.1.0
31
32
  Requires-Dist: keyring>=24.0.0
33
+ Requires-Dist: gspread>=6.0.0
34
+ Requires-Dist: google-auth>=2.22.0
32
35
  Provides-Extra: dev
33
36
  Requires-Dist: pytest>=7.0.0; extra == "dev"
34
37
  Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
@@ -38,6 +41,7 @@ Requires-Dist: mypy>=1.0.0; extra == "dev"
38
41
  Requires-Dist: isort>=5.12.0; extra == "dev"
39
42
  Requires-Dist: bandit>=1.7.0; extra == "dev"
40
43
  Requires-Dist: safety>=2.3.0; extra == "dev"
44
+ Requires-Dist: pre-commit>=3.5.0; extra == "dev"
41
45
  Dynamic: license-file
42
46
 
43
47
  # HPDE Analytics CLI
@@ -278,11 +282,11 @@ This project uses [Semantic Versioning (SemVer)](https://semver.org/):
278
282
  - **MINOR** version for new functionality in a backward compatible manner
279
283
  - **PATCH** version for backward compatible bug fixes
280
284
 
281
- **Current Version:** 1.0.0
285
+ **Current Version:** 2.0.0
282
286
 
283
287
  ## Authors
284
288
 
285
- * **Kevin Homan** - *Initial work and project lead* - WDCR Time Trials Chief Competition Director
289
+ * **Kevin Homan**
286
290
 
287
291
  ## License
288
292
 
@@ -236,11 +236,11 @@ This project uses [Semantic Versioning (SemVer)](https://semver.org/):
236
236
  - **MINOR** version for new functionality in a backward compatible manner
237
237
  - **PATCH** version for backward compatible bug fixes
238
238
 
239
- **Current Version:** 1.0.0
239
+ **Current Version:** 2.0.0
240
240
 
241
241
  ## Authors
242
242
 
243
- * **Kevin Homan** - *Initial work and project lead* - WDCR Time Trials Chief Competition Director
243
+ * **Kevin Homan**
244
244
 
245
245
  ## License
246
246
 
@@ -11,6 +11,10 @@ from requests_oauthlib import OAuth1Session
11
11
 
12
12
  from hpde_analytics_cli.auth.oauth import MSROAuth
13
13
 
14
+ # Error message constants
15
+ ERR_EVENT_ID_REQUIRED = "Event ID is required"
16
+ ERR_ORG_ID_REQUIRED = "Organization ID is required"
17
+
14
18
 
15
19
  class APIError(Exception):
16
20
  """Exception raised for API errors."""
@@ -46,6 +50,121 @@ class MSRClient:
46
50
  """Get an authenticated OAuth session."""
47
51
  return self.oauth.get_oauth_session()
48
52
 
53
+ def _execute_http_request(
54
+ self,
55
+ method: str,
56
+ session: OAuth1Session,
57
+ url: str,
58
+ params: Optional[Dict[str, Any]],
59
+ headers: Dict[str, str],
60
+ ):
61
+ """Execute HTTP request based on method type."""
62
+ if method.upper() == "GET":
63
+ return session.get(url, params=params, headers=headers)
64
+ elif method.upper() == "POST":
65
+ return session.post(url, data=params, headers=headers)
66
+ else:
67
+ raise APIError(f"Unsupported HTTP method: {method}")
68
+
69
+ def _handle_response_status(self, response, endpoint: str) -> Optional[Dict[str, Any]]:
70
+ """
71
+ Handle HTTP response status codes.
72
+
73
+ Returns:
74
+ Parsed response data if successful, None if should retry
75
+
76
+ Raises:
77
+ APIError: For non-retryable errors
78
+ """
79
+ if response.status_code == 200:
80
+ data = response.json()
81
+ # MSR wraps responses in {"response": {...}}
82
+ if isinstance(data, dict) and "response" in data:
83
+ data = data["response"]
84
+ return data
85
+
86
+ if response.status_code == 401:
87
+ raise APIError(
88
+ "Authentication failed - tokens may be invalid or expired",
89
+ status_code=401,
90
+ response_body=response.text,
91
+ )
92
+
93
+ if response.status_code == 403:
94
+ raise APIError(
95
+ "Access forbidden - insufficient permissions",
96
+ status_code=403,
97
+ response_body=response.text,
98
+ )
99
+
100
+ if response.status_code == 404:
101
+ raise APIError(
102
+ f"Resource not found: {endpoint}",
103
+ status_code=404,
104
+ response_body=response.text,
105
+ )
106
+
107
+ if response.status_code >= 500:
108
+ # Server error - signal retry
109
+ return None
110
+
111
+ raise APIError(
112
+ f"Request failed with status {response.status_code}",
113
+ status_code=response.status_code,
114
+ response_body=response.text,
115
+ )
116
+
117
+ def _should_retry_on_server_error(
118
+ self, response, endpoint: str, attempt: int, retries: int
119
+ ) -> APIError:
120
+ """
121
+ Handle server error response and determine if retry should occur.
122
+
123
+ Args:
124
+ response: HTTP response object
125
+ endpoint: API endpoint path
126
+ attempt: Current attempt number
127
+ retries: Maximum number of retries
128
+
129
+ Returns:
130
+ APIError to raise if no more retries
131
+
132
+ Raises:
133
+ APIError: If no more retries remain
134
+ """
135
+ error = APIError(
136
+ f"Server error: {response.status_code}",
137
+ status_code=response.status_code,
138
+ response_body=response.text,
139
+ )
140
+ if attempt < retries:
141
+ time.sleep(self.retry_delay * (attempt + 1))
142
+ else:
143
+ raise error
144
+ return error
145
+
146
+ def _should_retry_on_exception(self, e: Exception, attempt: int, retries: int) -> APIError:
147
+ """
148
+ Handle request exception and determine if retry should occur.
149
+
150
+ Args:
151
+ e: Exception that occurred
152
+ attempt: Current attempt number
153
+ retries: Maximum number of retries
154
+
155
+ Returns:
156
+ APIError to raise if no more retries
157
+
158
+ Raises:
159
+ APIError: If no more retries remain
160
+ """
161
+ error = APIError(f"Request failed: {str(e)}")
162
+ if attempt < retries:
163
+ time.sleep(self.retry_delay * (attempt + 1))
164
+ else:
165
+ raise error
166
+ return error
167
+
49
168
  def _request(
50
169
  self,
51
170
  method: str,
@@ -89,64 +208,21 @@ class MSRClient:
89
208
 
90
209
  for attempt in range(retries + 1):
91
210
  try:
92
- if method.upper() == "GET":
93
- response = session.get(url, params=params, headers=headers)
94
- elif method.upper() == "POST":
95
- response = session.post(url, data=params, headers=headers)
96
- else:
97
- raise APIError(f"Unsupported HTTP method: {method}")
98
-
99
- # Handle response
100
- if response.status_code == 200:
101
- data = response.json()
102
- # MSR wraps responses in {"response": {...}}
103
- if isinstance(data, dict) and "response" in data:
104
- data = data["response"]
105
- return data
106
- elif response.status_code == 401:
107
- raise APIError(
108
- "Authentication failed - tokens may be invalid or expired",
109
- status_code=401,
110
- response_body=response.text,
111
- )
112
- elif response.status_code == 403:
113
- raise APIError(
114
- "Access forbidden - insufficient permissions",
115
- status_code=403,
116
- response_body=response.text,
117
- )
118
- elif response.status_code == 404:
119
- raise APIError(
120
- f"Resource not found: {endpoint}",
121
- status_code=404,
122
- response_body=response.text,
123
- )
124
- elif response.status_code >= 500:
125
- # Server error - retry
126
- last_error = APIError(
127
- f"Server error: {response.status_code}",
128
- status_code=response.status_code,
129
- response_body=response.text,
130
- )
131
- if attempt < retries:
132
- time.sleep(self.retry_delay * (attempt + 1))
133
- continue
134
- raise last_error
135
- else:
136
- raise APIError(
137
- f"Request failed with status {response.status_code}",
138
- status_code=response.status_code,
139
- response_body=response.text,
140
- )
211
+ response = self._execute_http_request(method, session, url, params, headers)
212
+ result = self._handle_response_status(response, endpoint)
213
+
214
+ if result is not None:
215
+ return result
216
+
217
+ # Server error - retry
218
+ last_error = self._should_retry_on_server_error(
219
+ response, endpoint, attempt, retries
220
+ )
141
221
 
142
222
  except APIError:
143
223
  raise
144
224
  except Exception as e:
145
- last_error = APIError(f"Request failed: {str(e)}")
146
- if attempt < retries:
147
- time.sleep(self.retry_delay * (attempt + 1))
148
- continue
149
- raise last_error
225
+ last_error = self._should_retry_on_exception(e, attempt, retries)
150
226
 
151
227
  raise last_error
152
228
 
@@ -173,7 +249,7 @@ class MSRClient:
173
249
  """
174
250
  org_id = organization_id or self.organization_id
175
251
  if not org_id:
176
- raise ValueError("Organization ID is required")
252
+ raise ValueError(ERR_ORG_ID_REQUIRED)
177
253
 
178
254
  return self._request("GET", f"/rest/calendars/organization/{org_id}")
179
255
 
@@ -190,7 +266,7 @@ class MSRClient:
190
266
  Endpoint: GET /rest/events/{event_id}/entrylist
191
267
  """
192
268
  if not event_id:
193
- raise ValueError("Event ID is required")
269
+ raise ValueError(ERR_EVENT_ID_REQUIRED)
194
270
 
195
271
  return self._request("GET", f"/rest/events/{event_id}/entrylist")
196
272
 
@@ -207,7 +283,7 @@ class MSRClient:
207
283
  Endpoint: GET /rest/events/{event_id}/attendees
208
284
  """
209
285
  if not event_id:
210
- raise ValueError("Event ID is required")
286
+ raise ValueError(ERR_EVENT_ID_REQUIRED)
211
287
 
212
288
  return self._request("GET", f"/rest/events/{event_id}/attendees")
213
289
 
@@ -224,7 +300,7 @@ class MSRClient:
224
300
  Endpoint: GET /rest/events/{event_id}/assignments
225
301
  """
226
302
  if not event_id:
227
- raise ValueError("Event ID is required")
303
+ raise ValueError(ERR_EVENT_ID_REQUIRED)
228
304
 
229
305
  return self._request("GET", f"/rest/events/{event_id}/assignments")
230
306
 
@@ -241,10 +317,61 @@ class MSRClient:
241
317
  Endpoint: GET /rest/events/{event_id}/feeds/timing
242
318
  """
243
319
  if not event_id:
244
- raise ValueError("Event ID is required")
320
+ raise ValueError(ERR_EVENT_ID_REQUIRED)
245
321
 
246
322
  return self._request("GET", f"/rest/events/{event_id}/feeds/timing")
247
323
 
324
+ def _fetch_user_profile(self, results: Dict[str, Any]) -> None:
325
+ """Fetch and store user profile data."""
326
+ print(" Fetching /rest/me...")
327
+ try:
328
+ results["me"] = self.get_me()
329
+ print(" [OK] User profile retrieved")
330
+ except APIError as e:
331
+ print(f" [ERROR] {e}")
332
+ results["me"] = {"error": str(e)}
333
+
334
+ def _fetch_organization_calendar(
335
+ self, results: Dict[str, Any], event_id: Optional[str]
336
+ ) -> Optional[str]:
337
+ """
338
+ Fetch and store organization calendar data.
339
+
340
+ Returns:
341
+ Event ID if found in calendar, otherwise the input event_id
342
+ """
343
+ if not self.organization_id:
344
+ return event_id
345
+
346
+ print(f" Fetching organization calendar (org: {self.organization_id})...")
347
+ try:
348
+ results["calendar"] = self.get_organization_calendar()
349
+ print(" [OK] Calendar retrieved")
350
+
351
+ # Get first event ID if not provided
352
+ if not event_id:
353
+ events = results["calendar"].get("events", [])
354
+ if events:
355
+ event_id = events[0].get("id")
356
+ print(f" Using first event from calendar: {event_id}")
357
+ except APIError as e:
358
+ print(f" [ERROR] {e}")
359
+ results["calendar"] = {"error": str(e)}
360
+
361
+ return event_id
362
+
363
+ def _fetch_event_endpoint(
364
+ self, results: Dict[str, Any], key: str, method, description: str, event_id: str
365
+ ) -> None:
366
+ """Fetch data from a single event endpoint."""
367
+ print(f" Fetching {description} (event: {event_id})...")
368
+ try:
369
+ results[key] = method(event_id)
370
+ print(f" [OK] {description} retrieved")
371
+ except APIError as e:
372
+ print(f" [ERROR] {e}")
373
+ results[key] = {"error": str(e)}
374
+
248
375
  def get_all_endpoint_data(self, event_id: Optional[str] = None) -> Dict[str, Any]:
249
376
  """
250
377
  Fetch data from all available endpoints for field discovery.
@@ -255,33 +382,13 @@ class MSRClient:
255
382
  Returns:
256
383
  Dict with data from each endpoint, keyed by endpoint name
257
384
  """
258
- results = {}
385
+ results: Dict[str, Any] = {}
259
386
 
260
- # Always fetch user profile
261
- print(" Fetching /rest/me...")
262
- try:
263
- results["me"] = self.get_me()
264
- print(" [OK] User profile retrieved")
265
- except APIError as e:
266
- print(f" [ERROR] {e}")
267
- results["me"] = {"error": str(e)}
387
+ # Fetch user profile
388
+ self._fetch_user_profile(results)
268
389
 
269
- # Fetch organization calendar
270
- if self.organization_id:
271
- print(f" Fetching organization calendar (org: {self.organization_id})...")
272
- try:
273
- results["calendar"] = self.get_organization_calendar()
274
- print(" [OK] Calendar retrieved")
275
-
276
- # Get first event ID if not provided
277
- if not event_id:
278
- events = results["calendar"].get("events", [])
279
- if events:
280
- event_id = events[0].get("id")
281
- print(f" Using first event from calendar: {event_id}")
282
- except APIError as e:
283
- print(f" [ERROR] {e}")
284
- results["calendar"] = {"error": str(e)}
390
+ # Fetch organization calendar and potentially get event_id
391
+ event_id = self._fetch_organization_calendar(results, event_id)
285
392
 
286
393
  # Event-specific endpoints (require event_id)
287
394
  if event_id:
@@ -293,13 +400,7 @@ class MSRClient:
293
400
  ]
294
401
 
295
402
  for key, method, description in event_endpoints:
296
- print(f" Fetching {description} (event: {event_id})...")
297
- try:
298
- results[key] = method(event_id)
299
- print(f" [OK] {description} retrieved")
300
- except APIError as e:
301
- print(f" [ERROR] {e}")
302
- results[key] = {"error": str(e)}
403
+ self._fetch_event_endpoint(results, key, method, description, event_id)
303
404
  else:
304
405
  print(" [SKIP] No event ID available - skipping event-specific endpoints")
305
406
  print(" Use --event-id to specify an event, or ensure calendar has events")
@@ -35,8 +35,8 @@ class OAuthCallbackHandler(BaseHTTPRequestHandler):
35
35
  parsed = urlparse(self.path)
36
36
  params = parse_qs(parsed.query)
37
37
 
38
- # Log what we received for debugging
39
- print(f"\n [Callback] Received request: {self.path}")
38
+ # Log callback received (without sensitive path details)
39
+ print("\n [Callback] Received OAuth callback request")
40
40
 
41
41
  # Ignore non-callback requests (favicon, root path, etc.)
42
42
  if not parsed.path.startswith("/callback"):
@@ -55,7 +55,7 @@ class OAuthCallbackHandler(BaseHTTPRequestHandler):
55
55
  self.wfile.write(response.encode())
56
56
  return
57
57
 
58
- print(f" [Callback] OAuth parameters: {params}")
58
+ print(" [Callback] OAuth parameters received")
59
59
 
60
60
  # Extract OAuth parameters
61
61
  OAuthCallbackHandler.oauth_verifier = params.get("oauth_verifier", [None])[0]
@@ -236,7 +236,7 @@ class MSROAuth:
236
236
  if not self.request_token:
237
237
  raise Exception("Failed to obtain request token")
238
238
 
239
- print(f" Request token obtained: {self.request_token[:20]}...")
239
+ print(" Request token obtained: [REDACTED]")
240
240
  return self.request_token, self.request_token_secret
241
241
 
242
242
  def get_authorization_url(self) -> str:
@@ -255,8 +255,7 @@ class MSROAuth:
255
255
 
256
256
  print("\n[Step 2/3] User authorization required")
257
257
  print("-" * 60)
258
- print("Please visit this URL to authorize the application:")
259
- print(f"\n {auth_url}\n")
258
+ print("Opening authorization URL in browser...")
260
259
  print("-" * 60)
261
260
 
262
261
  return auth_url
@@ -294,7 +293,7 @@ class MSROAuth:
294
293
  if not self.access_token:
295
294
  raise Exception("Failed to obtain access token")
296
295
 
297
- print(f" Access token obtained: {self.access_token[:20]}...")
296
+ print(" Access token obtained: [REDACTED]")
298
297
  if self.profile_id:
299
298
  print(f" Profile ID: {self.profile_id}")
300
299
 
@@ -449,24 +448,24 @@ class MSROAuth:
449
448
  # Step 2: Get authorization URL
450
449
  auth_url = self.get_authorization_url()
451
450
 
451
+ # Always print the URL so the user can copy/paste it if needed
452
+ print(f"\nAuthorization URL:\n\n {auth_url}\n")
453
+
452
454
  # Open browser automatically if requested
453
455
  if auto_open_browser:
454
- print("\n" + "!" * 60)
455
- print("IMPORTANT: Copy and paste this URL into your browser:")
456
- print(f"\n {auth_url}\n")
457
- print("!" * 60)
458
- print("\nAttempting to open browser automatically...")
456
+ print("Attempting to open browser automatically...")
459
457
  try:
460
458
  webbrowser.open(auth_url)
459
+ print(" Browser opened. If nothing appeared, copy the URL above.")
461
460
  except Exception as e:
462
461
  print(f" Could not open browser automatically: {e}")
463
- print("\nIf the wrong page opens, manually navigate to the URL above.")
462
+ print(" Copy the URL above and open it in your browser manually.")
464
463
  else:
465
- print("\nPlease open the URL above in your browser.")
464
+ print("Copy the URL above and open it in your browser.")
466
465
 
467
466
  # Step 3: Wait for callback with verifier
468
467
  verifier = self._wait_for_callback(server)
469
- print(f"\n Received verifier: {verifier[:20]}...")
468
+ print("\n Received verifier: [REDACTED]")
470
469
 
471
470
  # Step 4: Exchange for access token
472
471
  self.get_access_token(verifier)
@@ -0,0 +1 @@
1
+ """Google Sheets integration module."""