hpde-analytics-cli 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.
- hpde_analytics_cli/__init__.py +4 -0
- hpde_analytics_cli/api/__init__.py +1 -0
- hpde_analytics_cli/api/client.py +325 -0
- hpde_analytics_cli/auth/__init__.py +1 -0
- hpde_analytics_cli/auth/credentials.py +256 -0
- hpde_analytics_cli/auth/oauth.py +529 -0
- hpde_analytics_cli/main.py +360 -0
- hpde_analytics_cli/utils/__init__.py +1 -0
- hpde_analytics_cli/utils/data_export.py +366 -0
- hpde_analytics_cli/utils/field_discovery.py +351 -0
- hpde_analytics_cli/utils/report_generator.py +459 -0
- hpde_analytics_cli-1.0.0.dist-info/METADATA +309 -0
- hpde_analytics_cli-1.0.0.dist-info/RECORD +17 -0
- hpde_analytics_cli-1.0.0.dist-info/WHEEL +5 -0
- hpde_analytics_cli-1.0.0.dist-info/entry_points.txt +2 -0
- hpde_analytics_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- hpde_analytics_cli-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Field discovery module for analyzing API responses.
|
|
3
|
+
|
|
4
|
+
Recursively parses JSON responses to enumerate all available fields,
|
|
5
|
+
detect data types, and generate a comprehensive field inventory.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FieldInfo:
|
|
16
|
+
"""Information about a discovered field."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, path: str, field_type: str, sample_value: Any = None):
|
|
19
|
+
self.path = path
|
|
20
|
+
self.field_type = field_type
|
|
21
|
+
self.sample_value = sample_value
|
|
22
|
+
self.occurrences = 1
|
|
23
|
+
self.nullable = False
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict:
|
|
26
|
+
"""Convert to dictionary for JSON serialization."""
|
|
27
|
+
result = {
|
|
28
|
+
"path": self.path,
|
|
29
|
+
"type": self.field_type,
|
|
30
|
+
"occurrences": self.occurrences,
|
|
31
|
+
"nullable": self.nullable,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Include sanitized sample value
|
|
35
|
+
if self.sample_value is not None:
|
|
36
|
+
result["sample"] = self._sanitize_sample(self.sample_value)
|
|
37
|
+
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
def _sanitize_sample(self, value: Any) -> Any:
|
|
41
|
+
"""Sanitize sample value to avoid exposing PII."""
|
|
42
|
+
if isinstance(value, str):
|
|
43
|
+
# Mask email addresses
|
|
44
|
+
if "@" in value and "." in value:
|
|
45
|
+
parts = value.split("@")
|
|
46
|
+
return f"{parts[0][:2]}***@{parts[1]}"
|
|
47
|
+
# Mask phone numbers (basic pattern)
|
|
48
|
+
if re.match(r"^[\d\-\(\)\s\+]+$", value) and len(value) >= 10:
|
|
49
|
+
return "***-***-" + value[-4:]
|
|
50
|
+
# Truncate long strings
|
|
51
|
+
if len(value) > 50:
|
|
52
|
+
return value[:47] + "..."
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class FieldDiscovery:
|
|
57
|
+
"""Discovers and catalogs fields from API responses."""
|
|
58
|
+
|
|
59
|
+
# Patterns for detecting special string types
|
|
60
|
+
DATE_PATTERNS = [
|
|
61
|
+
r"^\d{4}-\d{2}-\d{2}$", # ISO date
|
|
62
|
+
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", # ISO datetime
|
|
63
|
+
r"^\d{2}/\d{2}/\d{4}$", # US date
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
UUID_PATTERN = r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
|
67
|
+
URL_PATTERN = r"^https?://"
|
|
68
|
+
EMAIL_PATTERN = r"^[^@]+@[^@]+\.[^@]+$"
|
|
69
|
+
|
|
70
|
+
def __init__(self):
|
|
71
|
+
self.fields: Dict[str, FieldInfo] = {}
|
|
72
|
+
self.endpoint_fields: Dict[str, List[str]] = {}
|
|
73
|
+
|
|
74
|
+
def _detect_type(self, value: Any) -> str:
|
|
75
|
+
"""
|
|
76
|
+
Detect the type of a value, including special string subtypes.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
value: The value to analyze
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Type string (e.g., "string", "integer", "date", "url", etc.)
|
|
83
|
+
"""
|
|
84
|
+
if value is None:
|
|
85
|
+
return "null"
|
|
86
|
+
elif isinstance(value, bool):
|
|
87
|
+
return "boolean"
|
|
88
|
+
elif isinstance(value, int):
|
|
89
|
+
return "integer"
|
|
90
|
+
elif isinstance(value, float):
|
|
91
|
+
return "number"
|
|
92
|
+
elif isinstance(value, str):
|
|
93
|
+
# Check for special string types
|
|
94
|
+
for pattern in self.DATE_PATTERNS:
|
|
95
|
+
if re.match(pattern, value):
|
|
96
|
+
return "datetime" if "T" in value else "date"
|
|
97
|
+
|
|
98
|
+
if re.match(self.UUID_PATTERN, value):
|
|
99
|
+
return "uuid"
|
|
100
|
+
if re.match(self.URL_PATTERN, value):
|
|
101
|
+
return "url"
|
|
102
|
+
if re.match(self.EMAIL_PATTERN, value):
|
|
103
|
+
return "email"
|
|
104
|
+
|
|
105
|
+
return "string"
|
|
106
|
+
elif isinstance(value, list):
|
|
107
|
+
return "array"
|
|
108
|
+
elif isinstance(value, dict):
|
|
109
|
+
return "object"
|
|
110
|
+
else:
|
|
111
|
+
return "unknown"
|
|
112
|
+
|
|
113
|
+
def _parse_value(self, value: Any, path: str, endpoint: str) -> None:
|
|
114
|
+
"""
|
|
115
|
+
Recursively parse a value and discover fields.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
value: The value to parse
|
|
119
|
+
path: Current JSON path (e.g., "event.venue.name")
|
|
120
|
+
endpoint: Name of the endpoint this data came from
|
|
121
|
+
"""
|
|
122
|
+
value_type = self._detect_type(value)
|
|
123
|
+
|
|
124
|
+
if value_type == "object" and isinstance(value, dict):
|
|
125
|
+
# Recurse into object properties
|
|
126
|
+
for key, val in value.items():
|
|
127
|
+
child_path = f"{path}.{key}" if path else key
|
|
128
|
+
self._parse_value(val, child_path, endpoint)
|
|
129
|
+
|
|
130
|
+
elif value_type == "array" and isinstance(value, list):
|
|
131
|
+
# Record the array field itself
|
|
132
|
+
self._record_field(path, "array", value, endpoint)
|
|
133
|
+
|
|
134
|
+
# Parse array items (use first few items for type detection)
|
|
135
|
+
for i, item in enumerate(value[:3]): # Sample first 3 items
|
|
136
|
+
item_path = f"{path}[]"
|
|
137
|
+
self._parse_value(item, item_path, endpoint)
|
|
138
|
+
|
|
139
|
+
else:
|
|
140
|
+
# Leaf value - record it
|
|
141
|
+
self._record_field(path, value_type, value, endpoint)
|
|
142
|
+
|
|
143
|
+
def _record_field(self, path: str, field_type: str, value: Any, endpoint: str) -> None:
|
|
144
|
+
"""
|
|
145
|
+
Record a discovered field.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
path: JSON path to the field
|
|
149
|
+
field_type: Detected type
|
|
150
|
+
value: Sample value
|
|
151
|
+
endpoint: Source endpoint
|
|
152
|
+
"""
|
|
153
|
+
if not path:
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
if path in self.fields:
|
|
157
|
+
# Update existing field
|
|
158
|
+
field = self.fields[path]
|
|
159
|
+
field.occurrences += 1
|
|
160
|
+
if field_type == "null":
|
|
161
|
+
field.nullable = True
|
|
162
|
+
elif field.field_type == "null" and field_type != "null":
|
|
163
|
+
# Update type if we now have a non-null value
|
|
164
|
+
field.field_type = field_type
|
|
165
|
+
field.sample_value = value
|
|
166
|
+
else:
|
|
167
|
+
# New field
|
|
168
|
+
field = FieldInfo(path, field_type, value if field_type != "null" else None)
|
|
169
|
+
if field_type == "null":
|
|
170
|
+
field.nullable = True
|
|
171
|
+
self.fields[path] = field
|
|
172
|
+
|
|
173
|
+
# Track which endpoints have this field
|
|
174
|
+
if endpoint not in self.endpoint_fields:
|
|
175
|
+
self.endpoint_fields[endpoint] = []
|
|
176
|
+
if path not in self.endpoint_fields[endpoint]:
|
|
177
|
+
self.endpoint_fields[endpoint].append(path)
|
|
178
|
+
|
|
179
|
+
def analyze_response(self, data: Any, endpoint: str) -> int:
|
|
180
|
+
"""
|
|
181
|
+
Analyze an API response and discover fields.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
data: API response data (dict or list)
|
|
185
|
+
endpoint: Name of the endpoint
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Number of fields discovered
|
|
189
|
+
"""
|
|
190
|
+
if isinstance(data, dict) and "error" in data:
|
|
191
|
+
# Skip error responses
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
initial_count = len(self.fields)
|
|
195
|
+
self._parse_value(data, "", endpoint)
|
|
196
|
+
return len(self.fields) - initial_count
|
|
197
|
+
|
|
198
|
+
def analyze_all_responses(self, responses: Dict[str, Any]) -> Dict[str, int]:
|
|
199
|
+
"""
|
|
200
|
+
Analyze multiple API responses.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
responses: Dict mapping endpoint names to response data
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
Dict mapping endpoint names to field counts
|
|
207
|
+
"""
|
|
208
|
+
results = {}
|
|
209
|
+
for endpoint, data in responses.items():
|
|
210
|
+
count = self.analyze_response(data, endpoint)
|
|
211
|
+
results[endpoint] = count
|
|
212
|
+
return results
|
|
213
|
+
|
|
214
|
+
def get_inventory(self) -> dict:
|
|
215
|
+
"""
|
|
216
|
+
Get the complete field inventory.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Dict with metadata and field information
|
|
220
|
+
"""
|
|
221
|
+
# Group fields by endpoint
|
|
222
|
+
endpoints_data = {}
|
|
223
|
+
for endpoint, field_paths in self.endpoint_fields.items():
|
|
224
|
+
endpoints_data[endpoint] = {
|
|
225
|
+
"field_count": len(field_paths),
|
|
226
|
+
"fields": [
|
|
227
|
+
self.fields[path].to_dict()
|
|
228
|
+
for path in sorted(field_paths)
|
|
229
|
+
if path in self.fields
|
|
230
|
+
],
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
"metadata": {
|
|
235
|
+
"generated_at": datetime.now().isoformat(),
|
|
236
|
+
"version": "1.0.0",
|
|
237
|
+
"total_fields": len(self.fields),
|
|
238
|
+
"endpoints_analyzed": len(self.endpoint_fields),
|
|
239
|
+
},
|
|
240
|
+
"summary": {
|
|
241
|
+
"endpoints": list(self.endpoint_fields.keys()),
|
|
242
|
+
"field_counts": {
|
|
243
|
+
endpoint: len(paths) for endpoint, paths in self.endpoint_fields.items()
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
"endpoints": endpoints_data,
|
|
247
|
+
"all_fields": [self.fields[path].to_dict() for path in sorted(self.fields.keys())],
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
def get_fields_by_type(self) -> Dict[str, List[str]]:
|
|
251
|
+
"""
|
|
252
|
+
Group fields by their detected type.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
Dict mapping types to lists of field paths
|
|
256
|
+
"""
|
|
257
|
+
by_type: Dict[str, List[str]] = {}
|
|
258
|
+
for path, field in self.fields.items():
|
|
259
|
+
if field.field_type not in by_type:
|
|
260
|
+
by_type[field.field_type] = []
|
|
261
|
+
by_type[field.field_type].append(path)
|
|
262
|
+
return by_type
|
|
263
|
+
|
|
264
|
+
def print_summary(self) -> None:
|
|
265
|
+
"""Print a formatted summary of discovered fields."""
|
|
266
|
+
print("\n" + "=" * 60)
|
|
267
|
+
print("Field Discovery Summary")
|
|
268
|
+
print("=" * 60)
|
|
269
|
+
|
|
270
|
+
print(f"\nTotal unique fields discovered: {len(self.fields)}")
|
|
271
|
+
print(f"Endpoints analyzed: {len(self.endpoint_fields)}")
|
|
272
|
+
|
|
273
|
+
# Fields by type
|
|
274
|
+
by_type = self.get_fields_by_type()
|
|
275
|
+
print("\nFields by type:")
|
|
276
|
+
for field_type, paths in sorted(by_type.items()):
|
|
277
|
+
print(f" {field_type}: {len(paths)}")
|
|
278
|
+
|
|
279
|
+
# Fields per endpoint
|
|
280
|
+
print("\nFields per endpoint:")
|
|
281
|
+
for endpoint, paths in sorted(self.endpoint_fields.items()):
|
|
282
|
+
print(f" {endpoint}: {len(paths)} fields")
|
|
283
|
+
|
|
284
|
+
print("\n" + "-" * 60)
|
|
285
|
+
print("Detailed Field List")
|
|
286
|
+
print("-" * 60)
|
|
287
|
+
|
|
288
|
+
for endpoint, paths in sorted(self.endpoint_fields.items()):
|
|
289
|
+
print(f"\n[{endpoint}]")
|
|
290
|
+
for path in sorted(paths)[:20]: # Show first 20 per endpoint
|
|
291
|
+
field = self.fields.get(path)
|
|
292
|
+
if field:
|
|
293
|
+
nullable = " (nullable)" if field.nullable else ""
|
|
294
|
+
print(f" {path}: {field.field_type}{nullable}")
|
|
295
|
+
if len(paths) > 20:
|
|
296
|
+
print(f" ... and {len(paths) - 20} more fields")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def save_inventory(inventory: dict, output_path: str) -> None:
|
|
300
|
+
"""
|
|
301
|
+
Save field inventory to a JSON file.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
inventory: Field inventory dict
|
|
305
|
+
output_path: Path to output file
|
|
306
|
+
"""
|
|
307
|
+
path = Path(output_path)
|
|
308
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
309
|
+
|
|
310
|
+
with open(path, "w") as f:
|
|
311
|
+
json.dump(inventory, f, indent=2, default=str)
|
|
312
|
+
|
|
313
|
+
print(f"\nField inventory saved to: {path}")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def run_field_discovery(
|
|
317
|
+
api_data: Dict[str, Any],
|
|
318
|
+
output_path: Optional[str] = None,
|
|
319
|
+
verbose: bool = False,
|
|
320
|
+
) -> dict:
|
|
321
|
+
"""
|
|
322
|
+
Run field discovery on API response data.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
api_data: Dict of API responses keyed by endpoint name
|
|
326
|
+
output_path: Optional path to save JSON inventory
|
|
327
|
+
verbose: Print detailed output
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
Field inventory dict
|
|
331
|
+
"""
|
|
332
|
+
discovery = FieldDiscovery()
|
|
333
|
+
|
|
334
|
+
print("\nAnalyzing API responses...")
|
|
335
|
+
results = discovery.analyze_all_responses(api_data)
|
|
336
|
+
|
|
337
|
+
for endpoint, count in results.items():
|
|
338
|
+
status = "[OK]" if count > 0 else "[SKIP]"
|
|
339
|
+
print(f" {status} {endpoint}: {count} new fields")
|
|
340
|
+
|
|
341
|
+
# Print summary
|
|
342
|
+
discovery.print_summary()
|
|
343
|
+
|
|
344
|
+
# Get inventory
|
|
345
|
+
inventory = discovery.get_inventory()
|
|
346
|
+
|
|
347
|
+
# Save to file if path provided
|
|
348
|
+
if output_path:
|
|
349
|
+
save_inventory(inventory, output_path)
|
|
350
|
+
|
|
351
|
+
return inventory
|