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.
@@ -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