synapse-hub 2.0.1 → 2.1.0

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,606 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ tools/export_postman_collection.py — Enterprise API to Postman Collection v2.1.0 Exporter
5
+ -----------------------------------------------------------------------------------------
6
+ Converts OpenAPI / Swagger specifications, JSON endpoint schemas, or HTTP Archives (HAR)
7
+ into a production-ready Postman Collection v2.1.0 (with Pre-request Scripts, Tests Scripts,
8
+ Response Time Assertions, and Auto-Extracted Environment Variables).
9
+
10
+ Features:
11
+ 1. Ingests OpenAPI 3.0 / Swagger 2.0 JSON, HAR files, or built-in sample schemas.
12
+ 2. Generates organized folders by Tags / Resources with parameterized routes.
13
+ 3. Pre-request Scripts: Dynamic request timestamps, UUIDs, and automated auth headers.
14
+ 4. Test Scripts (pm.test):
15
+ - Expected HTTP Status assertions.
16
+ - Response time SLA latency assertions (< 1000ms).
17
+ - Response Content-Type JSON header validation.
18
+ - Autonomous Token & ID Extractor: Auto-saves `token`, `access_token`, or `id` into
19
+ `pm.environment` / `pm.collectionVariables` for chaining sequential API calls.
20
+ 5. Companion Environment Generator: Outputs `postman_environment.json` with base URLs.
21
+ 6. 100% UTF-8 No BOM, portable, Newman CLI and CI/CD compatible.
22
+
23
+ Usage:
24
+ python tools/export_postman_collection.py --spec swagger.json -o collection.json
25
+ python tools/export_postman_collection.py --har traffic.har -o collection.json
26
+ python tools/export_postman_collection.py --sample -o sample_collection.json --env-output sample_env.json
27
+ """
28
+
29
+ import os
30
+ import sys
31
+ import json
32
+ import uuid
33
+ import argparse
34
+ from typing import Dict, Any, List, Optional
35
+ from urllib.parse import urlparse, parse_qs
36
+
37
+ # Enforce UTF-8 stdout encoding for Windows/macOS/Linux console compatibility
38
+ if hasattr(sys.stdout, "reconfigure"):
39
+ try:
40
+ sys.stdout.reconfigure(encoding="utf-8")
41
+ sys.stderr.reconfigure(encoding="utf-8")
42
+ except Exception:
43
+ pass
44
+
45
+
46
+ def generate_uuid() -> str:
47
+ return str(uuid.uuid4())
48
+
49
+
50
+ def build_pre_request_script() -> List[str]:
51
+ """Generates standard Postman Pre-request script lines."""
52
+ return [
53
+ "// Auto-generated Pre-request Script by Agent-QA Hub",
54
+ "// 1. Dynamic Timestamp & Request ID generation",
55
+ "pm.variables.set('req_timestamp', new Date().toISOString());",
56
+ "pm.variables.set('req_nonce', Math.random().toString(36).substring(2, 12));",
57
+ "",
58
+ "// 2. Automated Bearer Token Injection if present in environment",
59
+ "var token = pm.environment.get('bearerToken') || pm.collectionVariables.get('bearerToken');",
60
+ "if (token && !pm.request.headers.has('Authorization')) {",
61
+ " pm.request.headers.add({ key: 'Authorization', value: 'Bearer ' + token });",
62
+ "}"
63
+ ]
64
+
65
+
66
+ def build_test_script(expected_status: int = 200, max_time_ms: int = 1000) -> List[str]:
67
+ """Generates standard Postman Test script lines."""
68
+ return [
69
+ "// Auto-generated Test Assertions by Agent-QA Hub",
70
+ f"// 1. Assert Status Code is {expected_status}",
71
+ f"pm.test('Status code is {expected_status}', function () {{",
72
+ f" pm.expect(pm.response.code).to.be.oneOf([{expected_status}, 200, 201]);",
73
+ "});",
74
+ "",
75
+ f"// 2. Assert SLA Latency (< {max_time_ms}ms)",
76
+ f"pm.test('Response time is below {max_time_ms}ms SLA', function () {{",
77
+ f" pm.expect(pm.response.responseTime).to.be.below({max_time_ms});",
78
+ "});",
79
+ "",
80
+ "// 3. Assert Content-Type JSON Header",
81
+ "pm.test('Response header Content-Type is JSON', function () {",
82
+ " if (pm.response.code !== 204) {",
83
+ " pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json');",
84
+ " }",
85
+ "});",
86
+ "",
87
+ "// 4. Autonomous Token / Entity ID Extraction & Session Chaining",
88
+ "if (pm.response.code >= 200 && pm.response.code < 300) {",
89
+ " try {",
90
+ " var jsonBody = pm.response.json();",
91
+ " // Auto-extract JWT / OAuth tokens",
92
+ " var authToken = jsonBody.token || jsonBody.access_token || (jsonBody.data && (jsonBody.data.token || jsonBody.data.access_token));",
93
+ " if (authToken) {",
94
+ " pm.environment.set('bearerToken', authToken);",
95
+ " pm.collectionVariables.set('bearerToken', authToken);",
96
+ " console.log('[Agent-QA] Captured dynamic bearerToken: ' + authToken.substring(0, 15) + '...');",
97
+ " }",
98
+ " // Auto-extract newly created ID",
99
+ " var entityId = jsonBody.id || jsonBody.code || (jsonBody.data && (jsonBody.data.id || jsonBody.data.code));",
100
+ " if (entityId) {",
101
+ " pm.environment.set('lastCreatedId', String(entityId));",
102
+ " }",
103
+ " } catch (e) {",
104
+ " // Response is non-JSON or empty body",
105
+ " }",
106
+ "}"
107
+ ]
108
+
109
+
110
+ def mock_schema_payload(schema: Dict[str, Any], depth: int = 0) -> Any:
111
+ """Recursively generates realistic mock JSON payload from OpenAPI schema."""
112
+ if depth > 3 or not schema:
113
+ return {}
114
+
115
+ stype = schema.get("type", "object")
116
+
117
+ if stype == "string":
118
+ fmt = schema.get("format", "")
119
+ if fmt == "email":
120
+ return "{{$randomEmail}}"
121
+ elif fmt == "uuid":
122
+ return "{{$guid}}"
123
+ elif fmt in ("date-time", "date"):
124
+ return "{{$isoTimestamp}}"
125
+ return schema.get("example") or f"AUTO_{{{{$randomAlphaNumeric}}}}"
126
+
127
+ elif stype in ("integer", "number"):
128
+ return schema.get("example") or schema.get("minimum", 100)
129
+
130
+ elif stype == "boolean":
131
+ return schema.get("example", True)
132
+
133
+ elif stype == "array":
134
+ items_schema = schema.get("items", {})
135
+ return [mock_schema_payload(items_schema, depth + 1)]
136
+
137
+ elif stype == "object":
138
+ props = schema.get("properties", {})
139
+ obj = {}
140
+ for p_name, p_schema in props.items():
141
+ obj[p_name] = mock_schema_payload(p_schema, depth + 1)
142
+ return obj
143
+
144
+ return {}
145
+
146
+
147
+ def parse_openapi_spec(spec_data: Dict[str, Any], base_url_override: Optional[str] = None) -> Dict[str, Any]:
148
+ """Parses OpenAPI 2.0 / 3.0 dictionary into a Postman Collection v2.1.0 structure."""
149
+ info_dict = spec_data.get("info", {})
150
+ col_name = info_dict.get("title", "Enterprise API Collection")
151
+ col_desc = info_dict.get("description", "Auto-generated Postman Collection from OpenAPI / Swagger")
152
+
153
+ # Determine Base URL
154
+ base_url = "{{baseUrl}}"
155
+ if base_url_override:
156
+ base_url_default = base_url_override
157
+ elif "servers" in spec_data and spec_data["servers"]:
158
+ base_url_default = spec_data["servers"][0].get("url", "https://api.staging.enterprise.local")
159
+ elif "host" in spec_data:
160
+ schemes = spec_data.get("schemes", ["https"])
161
+ base_url_default = f"{schemes[0]}://{spec_data['host']}{spec_data.get('basePath', '')}"
162
+ else:
163
+ base_url_default = "https://api.staging.enterprise.local"
164
+
165
+ # Postman root structure
166
+ collection = {
167
+ "info": {
168
+ "_postman_id": generate_uuid(),
169
+ "name": col_name,
170
+ "description": col_desc,
171
+ "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
172
+ },
173
+ "variable": [
174
+ {
175
+ "key": "baseUrl",
176
+ "value": base_url_default,
177
+ "type": "string"
178
+ },
179
+ {
180
+ "key": "bearerToken",
181
+ "value": "",
182
+ "type": "string"
183
+ }
184
+ ],
185
+ "item": []
186
+ }
187
+
188
+ folders: Dict[str, List[Dict[str, Any]]] = {}
189
+ paths = spec_data.get("paths", {})
190
+
191
+ for path, path_item in paths.items():
192
+ for method in ("get", "post", "put", "patch", "delete", "options", "head"):
193
+ if method not in path_item:
194
+ continue
195
+
196
+ op = path_item[method]
197
+ summary = op.get("summary") or f"{method.upper()} {path}"
198
+ tags = op.get("tags") or ["Default"]
199
+ tag_name = tags[0]
200
+
201
+ # Request Headers
202
+ headers = [
203
+ {"key": "Content-Type", "value": "application/json", "type": "text"},
204
+ {"key": "Accept", "value": "application/json", "type": "text"}
205
+ ]
206
+
207
+ # Query Parameters
208
+ query_params = []
209
+ for param in op.get("parameters", []):
210
+ p_in = param.get("in")
211
+ if p_in == "query":
212
+ query_params.append({
213
+ "key": param.get("name"),
214
+ "value": str(param.get("example") or param.get("default", "")),
215
+ "description": param.get("description", "")
216
+ })
217
+ elif p_in == "header":
218
+ headers.append({
219
+ "key": param.get("name"),
220
+ "value": str(param.get("example", "")),
221
+ "type": "text",
222
+ "description": param.get("description", "")
223
+ })
224
+
225
+ # Request Body
226
+ body_dict = None
227
+ if method in ("post", "put", "patch"):
228
+ # OpenAPI 3.0 requestBody
229
+ req_body = op.get("requestBody", {})
230
+ content = req_body.get("content", {})
231
+ json_content = content.get("application/json", {})
232
+ schema = json_content.get("schema")
233
+
234
+ # Swagger 2.0 body parameter fallback
235
+ if not schema:
236
+ for p in op.get("parameters", []):
237
+ if p.get("in") == "body":
238
+ schema = p.get("schema")
239
+ break
240
+
241
+ if schema:
242
+ payload = mock_schema_payload(schema)
243
+ body_dict = {
244
+ "mode": "raw",
245
+ "raw": json.dumps(payload, indent=2, ensure_ascii=False),
246
+ "options": {"raw": {"language": "json"}}
247
+ }
248
+ else:
249
+ body_dict = {
250
+ "mode": "raw",
251
+ "raw": "{}",
252
+ "options": {"raw": {"language": "json"}}
253
+ }
254
+
255
+ # URL decomposition
256
+ path_segments = [seg for seg in path.strip("/").split("/") if seg]
257
+ url_obj = {
258
+ "raw": f"{{{{baseUrl}}}}{path}",
259
+ "host": ["{{baseUrl}}"],
260
+ "path": path_segments
261
+ }
262
+ if query_params:
263
+ url_obj["query"] = query_params
264
+
265
+ # Status code for assertion
266
+ responses = op.get("responses", {})
267
+ expected_status = 200
268
+ for code_str in responses.keys():
269
+ if code_str.isdigit():
270
+ expected_status = int(code_str)
271
+ break
272
+
273
+ postman_request = {
274
+ "name": summary,
275
+ "event": [
276
+ {
277
+ "listen": "prerequest",
278
+ "script": {
279
+ "type": "text/javascript",
280
+ "exec": build_pre_request_script()
281
+ }
282
+ },
283
+ {
284
+ "listen": "test",
285
+ "script": {
286
+ "type": "text/javascript",
287
+ "exec": build_test_script(expected_status=expected_status)
288
+ }
289
+ }
290
+ ],
291
+ "request": {
292
+ "method": method.upper(),
293
+ "header": headers,
294
+ "url": url_obj,
295
+ "description": op.get("description", "")
296
+ },
297
+ "response": []
298
+ }
299
+
300
+ if body_dict:
301
+ postman_request["request"]["body"] = body_dict
302
+
303
+ if tag_name not in folders:
304
+ folders[tag_name] = []
305
+ folders[tag_name].append(postman_request)
306
+
307
+ # Attach folders into collection
308
+ for folder_name, items in folders.items():
309
+ collection["item"].append({
310
+ "name": folder_name,
311
+ "item": items
312
+ })
313
+
314
+ return collection
315
+
316
+
317
+ def parse_har_file(har_data: Dict[str, Any], base_url_override: Optional[str] = None) -> Dict[str, Any]:
318
+ """Parses HTTP Archive (.har) into a Postman Collection v2.1.0."""
319
+ entries = har_data.get("log", {}).get("entries", [])
320
+ collection = {
321
+ "info": {
322
+ "_postman_id": generate_uuid(),
323
+ "name": "HAR Session API Replay",
324
+ "description": "Replay collection generated from production HAR network telemetry",
325
+ "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
326
+ },
327
+ "variable": [
328
+ {"key": "baseUrl", "value": base_url_override or "https://api.staging.enterprise.local", "type": "string"},
329
+ {"key": "bearerToken", "value": "", "type": "string"}
330
+ ],
331
+ "item": []
332
+ }
333
+
334
+ requests_by_host: Dict[str, List[Dict[str, Any]]] = {}
335
+
336
+ for entry in entries:
337
+ req = entry.get("request", {})
338
+ resp = entry.get("response", {})
339
+ url_str = req.get("url", "")
340
+ method = req.get("method", "GET").upper()
341
+
342
+ parsed_url = urlparse(url_str)
343
+ # Filter static files
344
+ if any(parsed_url.path.lower().endswith(ext) for ext in (".png", ".jpg", ".css", ".js", ".woff2", ".ico")):
345
+ continue
346
+
347
+ host = parsed_url.netloc or "DefaultHost"
348
+ path = parsed_url.path
349
+ query = parse_qs(parsed_url.query)
350
+
351
+ query_list = [{"key": k, "value": v[0] if v else ""} for k, v in query.items()]
352
+
353
+ headers = []
354
+ for h in req.get("headers", []):
355
+ name = h.get("name", "")
356
+ if name.lower() not in ("cookie", "host", "content-length"):
357
+ headers.append({"key": name, "value": h.get("value", ""), "type": "text"})
358
+
359
+ body_dict = None
360
+ post_data = req.get("postData")
361
+ if post_data and post_data.get("text"):
362
+ body_dict = {
363
+ "mode": "raw",
364
+ "raw": post_data.get("text"),
365
+ "options": {"raw": {"language": "json"}}
366
+ }
367
+
368
+ status_code = resp.get("status", 200)
369
+
370
+ postman_item = {
371
+ "name": f"{method} {path}",
372
+ "event": [
373
+ {
374
+ "listen": "prerequest",
375
+ "script": {"type": "text/javascript", "exec": build_pre_request_script()}
376
+ },
377
+ {
378
+ "listen": "test",
379
+ "script": {"type": "text/javascript", "exec": build_test_script(expected_status=status_code)}
380
+ }
381
+ ],
382
+ "request": {
383
+ "method": method,
384
+ "header": headers,
385
+ "url": {
386
+ "raw": url_str,
387
+ "host": [parsed_url.scheme + "://" + parsed_url.netloc],
388
+ "path": [p for p in path.strip("/").split("/") if p],
389
+ "query": query_list
390
+ }
391
+ },
392
+ "response": []
393
+ }
394
+ if body_dict:
395
+ postman_item["request"]["body"] = body_dict
396
+
397
+ if host not in requests_by_host:
398
+ requests_by_host[host] = []
399
+ requests_by_host[host].append(postman_item)
400
+
401
+ for host_name, items in requests_by_host.items():
402
+ collection["item"].append({"name": host_name, "item": items})
403
+
404
+ return collection
405
+
406
+
407
+ def generate_postman_environment(name: str = "Enterprise-Staging-Env", base_url: str = "https://api.staging.enterprise.local") -> Dict[str, Any]:
408
+ """Generates companion Postman environment JSON for Newman/CLI integration."""
409
+ return {
410
+ "id": generate_uuid(),
411
+ "name": name,
412
+ "values": [
413
+ {"key": "baseUrl", "value": base_url, "type": "default", "enabled": True},
414
+ {"key": "bearerToken", "value": "", "type": "secret", "enabled": True},
415
+ {"key": "lastCreatedId", "value": "", "type": "default", "enabled": True}
416
+ ],
417
+ "_postman_variable_scope": "environment"
418
+ }
419
+
420
+
421
+ def get_sample_spec() -> Dict[str, Any]:
422
+ """Provides standard built-in OpenAPI 3.0 spec for testing & demonstration."""
423
+ return {
424
+ "openapi": "3.0.0",
425
+ "info": {
426
+ "title": "Enterprise E-Commerce & Auth Service",
427
+ "version": "1.0.0",
428
+ "description": "Sample API spec for Postman & JMeter automated generation"
429
+ },
430
+ "servers": [{"url": "https://api.staging.enterprise.local"}],
431
+ "paths": {
432
+ "/api/v1/auth/login": {
433
+ "post": {
434
+ "tags": ["Authentication"],
435
+ "summary": "Authenticate user and issue JWT token",
436
+ "requestBody": {
437
+ "content": {
438
+ "application/json": {
439
+ "schema": {
440
+ "type": "object",
441
+ "required": ["username", "password"],
442
+ "properties": {
443
+ "username": {"type": "string", "example": "admin_qa"},
444
+ "password": {"type": "string", "example": "SecretPass#2026"}
445
+ }
446
+ }
447
+ }
448
+ }
449
+ },
450
+ "responses": {
451
+ "200": {
452
+ "description": "JWT Token generated successfully",
453
+ "content": {
454
+ "application/json": {
455
+ "schema": {
456
+ "type": "object",
457
+ "properties": {
458
+ "token": {"type": "string"},
459
+ "expires_in": {"type": "integer"}
460
+ }
461
+ }
462
+ }
463
+ }
464
+ }
465
+ }
466
+ }
467
+ },
468
+ "/api/v1/categories": {
469
+ "get": {
470
+ "tags": ["Category Management"],
471
+ "summary": "List categories with pagination",
472
+ "parameters": [
473
+ {"name": "page", "in": "query", "example": 1},
474
+ {"name": "limit", "in": "query", "example": 20}
475
+ ],
476
+ "responses": {"200": {"description": "List of categories"}}
477
+ },
478
+ "post": {
479
+ "tags": ["Category Management"],
480
+ "summary": "Create a new category",
481
+ "requestBody": {
482
+ "content": {
483
+ "application/json": {
484
+ "schema": {
485
+ "type": "object",
486
+ "required": ["code", "name"],
487
+ "properties": {
488
+ "code": {"type": "string", "minLength": 3, "maxLength": 20},
489
+ "name": {"type": "string", "minLength": 1, "maxLength": 100},
490
+ "priority": {"type": "integer", "minimum": 1, "maximum": 100}
491
+ }
492
+ }
493
+ }
494
+ }
495
+ },
496
+ "responses": {"201": {"description": "Category created successfully"}}
497
+ }
498
+ },
499
+ "/api/v1/categories/{id}": {
500
+ "delete": {
501
+ "tags": ["Category Management"],
502
+ "summary": "Delete category by ID (Idempotent cleanup)",
503
+ "parameters": [
504
+ {"name": "id", "in": "path", "required": True, "example": "{{lastCreatedId}}"}
505
+ ],
506
+ "responses": {"204": {"description": "Deleted"}}
507
+ }
508
+ }
509
+ }
510
+ }
511
+
512
+
513
+ def main():
514
+ parser = argparse.ArgumentParser(
515
+ description="Export OpenAPI or HAR to Postman Collection v2.1.0 with auto-asserts & token chaining."
516
+ )
517
+ parser.add_argument("--spec", "-s", help="Path to OpenAPI / Swagger JSON specification file.")
518
+ parser.add_argument("--har", help="Path to HTTP Archive (.har) recording file.")
519
+ parser.add_argument("--base-url", "-b", help="Override base URL for {{baseUrl}} parameter.")
520
+ parser.add_argument("--output", "-o", help="Target output Postman Collection file path (.json).")
521
+ parser.add_argument("--env-output", help="Optional companion Postman Environment output file (.json).")
522
+ parser.add_argument("--sample", action="store_true", help="Generate sample collection using built-in enterprise schema.")
523
+ parser.add_argument("--portman", action="store_true", help="Delegate conversion to @apideck/portman via npx (contract-driven assertions).")
524
+ parser.add_argument("--newman", action="store_true", help="Automatically trigger headless Newman execution on the generated collection.")
525
+
526
+ args = parser.parse_args()
527
+
528
+ # If --portman is requested, run via npx @apideck/portman
529
+ if args.portman:
530
+ import subprocess
531
+ import tempfile
532
+ import shutil
533
+
534
+ target_output = args.output or "postman_collection.json"
535
+ spec_path = args.spec
536
+
537
+ # Handle sample spec if no explicit spec file provided
538
+ temp_spec_file = None
539
+ if not spec_path or args.sample:
540
+ fd, temp_spec_file = tempfile.mkstemp(suffix=".json", prefix="sample_spec_")
541
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
542
+ json.dump(get_sample_spec(), f)
543
+ spec_path = temp_spec_file
544
+
545
+ portman_cmd = ["npx", "--yes", "@apideck/portman", "-l", spec_path, "-o", target_output]
546
+ if args.base_url:
547
+ portman_cmd.extend(["-b", args.base_url])
548
+ if args.newman:
549
+ portman_cmd.extend(["-n"])
550
+
551
+ print(f"⚡ [PORTMAN BRIDGE] Executing: {' '.join(portman_cmd)}")
552
+ try:
553
+ res = subprocess.run(portman_cmd, check=True)
554
+ print(f"✅ [PORTMAN SUCCESS] Collection generated via @apideck/portman: {target_output}")
555
+ if temp_spec_file and os.path.exists(temp_spec_file):
556
+ os.remove(temp_spec_file)
557
+ return
558
+ except (subprocess.CalledProcessError, FileNotFoundError) as e:
559
+ print(f"⚠️ [PORTMAN FALLBACK] Could not run npx portman ({e}). Falling back to internal Pure-Python engine.")
560
+ if temp_spec_file and os.path.exists(temp_spec_file):
561
+ os.remove(temp_spec_file)
562
+
563
+ if args.sample or (not args.spec and not args.har):
564
+ spec_data = get_sample_spec()
565
+ collection = parse_openapi_spec(spec_data, base_url_override=args.base_url)
566
+ elif args.har:
567
+ if not os.path.exists(args.har):
568
+ print(f"❌ Error: HAR file not found: {args.har}", file=sys.stderr)
569
+ sys.exit(1)
570
+ with open(args.har, "r", encoding="utf-8") as f:
571
+ har_data = json.load(f)
572
+ collection = parse_har_file(har_data, base_url_override=args.base_url)
573
+ else:
574
+ if not os.path.exists(args.spec):
575
+ print(f"❌ Error: OpenAPI specification file not found: {args.spec}", file=sys.stderr)
576
+ sys.exit(1)
577
+ with open(args.spec, "r", encoding="utf-8") as f:
578
+ spec_data = json.load(f)
579
+ collection = parse_openapi_spec(spec_data, base_url_override=args.base_url)
580
+
581
+ out_json = json.dumps(collection, indent=2, ensure_ascii=False)
582
+
583
+ if args.output:
584
+ out_dir = os.path.dirname(os.path.abspath(args.output))
585
+ if out_dir and not os.path.exists(out_dir):
586
+ os.makedirs(out_dir, exist_ok=True)
587
+ with open(args.output, "w", encoding="utf-8") as f:
588
+ f.write(out_json)
589
+ print(f"✅ [POSTMAN EXPORT] Successfully created Postman Collection v2.1.0: {args.output}")
590
+ else:
591
+ print(out_json)
592
+
593
+ # Optional Companion Environment
594
+ if args.env_output:
595
+ base_u = args.base_url or "https://api.staging.enterprise.local"
596
+ env_dict = generate_postman_environment(name="Agent-QA-Staging-Env", base_url=base_u)
597
+ env_dir = os.path.dirname(os.path.abspath(args.env_output))
598
+ if env_dir and not os.path.exists(env_dir):
599
+ os.makedirs(env_dir, exist_ok=True)
600
+ with open(args.env_output, "w", encoding="utf-8") as f:
601
+ json.dump(env_dict, f, indent=2, ensure_ascii=False)
602
+ print(f"✅ [ENVIRONMENT EXPORT] Successfully created Postman Environment: {args.env_output}")
603
+
604
+
605
+ if __name__ == "__main__":
606
+ main()