unitysvc-services 0.1.5__tar.gz → 0.1.6__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 (64) hide show
  1. {unitysvc_services-0.1.5/src/unitysvc_services.egg-info → unitysvc_services-0.1.6}/PKG-INFO +1 -1
  2. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/docs/code-examples.md +144 -30
  3. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/docs/data-structure.md +142 -0
  4. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/pyproject.toml +1 -1
  5. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/src/unitysvc_services/models/base.py +0 -12
  6. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/src/unitysvc_services/publisher.py +52 -2
  7. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/src/unitysvc_services/test.py +37 -10
  8. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/src/unitysvc_services/utils.py +65 -2
  9. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6/src/unitysvc_services.egg-info}/PKG-INFO +1 -1
  10. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/tests/test_utils.py +178 -0
  11. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/CONTRIBUTING.md +0 -0
  12. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/HISTORY.md +0 -0
  13. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/LICENSE +0 -0
  14. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/MANIFEST.in +0 -0
  15. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/README.md +0 -0
  16. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/docs/api-reference.md +0 -0
  17. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/docs/cli-reference.md +0 -0
  18. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/docs/contributing.md +0 -0
  19. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/docs/development.md +0 -0
  20. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/docs/documenting-services.md +0 -0
  21. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/docs/file-schemas.md +0 -0
  22. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/docs/getting-started.md +0 -0
  23. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/docs/index.md +0 -0
  24. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/docs/installation.md +0 -0
  25. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/docs/usage.md +0 -0
  26. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/docs/workflows.md +0 -0
  27. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/setup.cfg +0 -0
  28. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/src/unitysvc_services/__init__.py +0 -0
  29. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/src/unitysvc_services/api.py +0 -0
  30. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/src/unitysvc_services/cli.py +0 -0
  31. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/src/unitysvc_services/format_data.py +0 -0
  32. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/src/unitysvc_services/list.py +0 -0
  33. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/src/unitysvc_services/models/__init__.py +0 -0
  34. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/src/unitysvc_services/models/listing_v1.py +0 -0
  35. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/src/unitysvc_services/models/provider_v1.py +0 -0
  36. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/src/unitysvc_services/models/seller_v1.py +0 -0
  37. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/src/unitysvc_services/models/service_v1.py +0 -0
  38. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/src/unitysvc_services/populate.py +0 -0
  39. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/src/unitysvc_services/py.typed +0 -0
  40. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/src/unitysvc_services/query.py +0 -0
  41. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/src/unitysvc_services/scaffold.py +0 -0
  42. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/src/unitysvc_services/update.py +0 -0
  43. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/src/unitysvc_services/validator.py +0 -0
  44. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/src/unitysvc_services.egg-info/SOURCES.txt +0 -0
  45. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/src/unitysvc_services.egg-info/dependency_links.txt +0 -0
  46. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/src/unitysvc_services.egg-info/entry_points.txt +0 -0
  47. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/src/unitysvc_services.egg-info/requires.txt +0 -0
  48. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/src/unitysvc_services.egg-info/top_level.txt +0 -0
  49. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/tests/__init__.py +0 -0
  50. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/tests/example_data/README.md +0 -0
  51. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/tests/example_data/provider1/README.md +0 -0
  52. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/tests/example_data/provider1/provider.toml +0 -0
  53. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/tests/example_data/provider1/services/service1/code-example.md +0 -0
  54. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/tests/example_data/provider1/services/service1/service.toml +0 -0
  55. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/tests/example_data/provider1/services/service1/svcreseller.toml +0 -0
  56. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/tests/example_data/provider1/terms-of-service.md +0 -0
  57. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/tests/example_data/provider2/README.md +0 -0
  58. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/tests/example_data/provider2/provider.json +0 -0
  59. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/tests/example_data/provider2/services/service2/code-example.md +0 -0
  60. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/tests/example_data/provider2/services/service2/service.json +0 -0
  61. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/tests/example_data/provider2/services/service2/svcreseller.json +0 -0
  62. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/tests/example_data/provider2/terms-of-service.md +0 -0
  63. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/tests/example_data/seller.json +0 -0
  64. {unitysvc_services-0.1.5 → unitysvc_services-0.1.6}/tests/test_validator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unitysvc-services
3
- Version: 0.1.5
3
+ Version: 0.1.6
4
4
  Summary: SDK for digital service providers on UnitySVC
5
5
  Author-email: Bo Peng <bo.peng@unitysvc.com>
6
6
  Maintainer-email: Bo Peng <bo.peng@unitysvc.com>
@@ -77,8 +77,10 @@ Code examples are referenced in your `listing.json` or `listing.toml` file under
77
77
  "file_path": "../../docs/example.py.j2",
78
78
  "mime_type": "python",
79
79
  "is_public": true,
80
- "requirements": ["httpx"],
81
- "expect": "✓ Test passed"
80
+ "meta": {
81
+ "requirements": ["httpx"],
82
+ "expect": "✓ Test passed"
83
+ }
82
84
  }
83
85
  ]
84
86
  }
@@ -94,10 +96,25 @@ Code examples are referenced in your `listing.json` or `listing.toml` file under
94
96
  - **`mime_type`**: File type (`python`, `javascript`, `shell`, etc.)
95
97
  - **`is_public`**: Should be `true` for code examples
96
98
 
97
- **Optional but Recommended Fields:**
99
+ **Optional but Recommended Fields (in `meta` object):**
98
100
 
99
- - **`requirements`**: Package dependencies (e.g., `["httpx", "openai"]`)
100
- - **`expect`**: Expected substring in stdout for validation (e.g., `" Test passed"`)
101
+ - **`meta.requirements`**: _(User-maintained)_ Package dependencies needed to run the code example
102
+ - For Python: PyPI packages (e.g., `["httpx", "openai"]`)
103
+ - For JavaScript: npm packages (e.g., `["node-fetch"]`)
104
+ - For Shell scripts: commands (e.g., `["curl"]`)
105
+ - Helps users understand what to install before running the example
106
+ - **`meta.expect`**: _(User-maintained)_ Expected substring in stdout for validation
107
+ - Examples: `"✓ Test passed"`, `"\"choices\""`, `"Status: 200"`
108
+ - If specified, test passes only if stdout contains this string
109
+ - Without this field, tests only check exit code (0 = pass, non-zero = fail)
110
+
111
+ **System-Maintained Fields (in `meta` object):**
112
+
113
+ - **`meta.output`**: _(System-maintained)_ Actual output from successful test execution
114
+ - Automatically populated by `usvc test run` when a test passes
115
+ - Contains the stdout from the last successful test run
116
+ - Included in your service listing during `usvc publish`
117
+ - Displayed alongside code examples for documentation
101
118
 
102
119
  ### 4. Use Relative Paths
103
120
 
@@ -220,8 +237,11 @@ usvc test run --verbose
220
237
  3. Sets environment variables (`API_KEY`, `API_ENDPOINT`) from provider credentials
221
238
  4. Executes the code example using appropriate interpreter (python3, node, bash)
222
239
  5. Validates results:
223
- - Test passes if exit code is 0 AND (no `expect` field OR expected string found in stdout)
240
+ - Test passes if exit code is 0 AND (no `meta.expect` field OR expected string found in stdout)
224
241
  - Test fails if exit code is non-zero OR expected string not found
242
+ 6. **Saves output**: When a test passes, stdout is saved to listing directory as `{listing_stem}_{code_filename}.out`
243
+ - Example: For `svclisting.json` with code file `test.py`, output is saved as `svclisting_test.py.out`
244
+ - Saved in the same directory as the listing file for easy version control
225
245
 
226
246
  **Failed test debugging:**
227
247
 
@@ -382,8 +402,10 @@ Reference the code example in your `listing.json` file.
382
402
  "file_path": "../../docs/test.py.j2",
383
403
  "mime_type": "python",
384
404
  "is_public": true,
385
- "requirements": ["httpx"],
386
- "expect": "✓ Test passed"
405
+ "meta": {
406
+ "requirements": ["httpx"],
407
+ "expect": "✓ Test passed"
408
+ }
387
409
  }
388
410
  ]
389
411
  }
@@ -398,17 +420,23 @@ Reference the code example in your `listing.json` file.
398
420
  - `file_path`: Relative path from listing file to your `.j2` template
399
421
  - `mime_type`: File type (`python`, `javascript`, `shell`, etc.)
400
422
  - `is_public`: Should be `true` for code examples
401
- - `requirements`: **[Optional]** List of package dependencies needed to run the code example
402
- - For Python: PyPI packages (e.g., `["httpx", "openai"]`)
403
- - For JavaScript: npm packages (e.g., `["node-fetch"]`)
404
- - For Shell scripts: commands (e.g., `["curl"]`)
405
- - Helps users understand what to install before running the example
406
- - `expect`: **[Optional but strongly recommended]** Expected substring that should appear in stdout when the test passes
407
- - Examples:
408
- - `"✓ Test passed"` - Explicit success message
409
- - `"\"choices\""` - Check for JSON field in API response
410
- - `"Status: 200"` - Check for HTTP status
411
- - Without this field, tests only check exit code (0 = pass, non-zero = fail), which is unreliable.
423
+ - `meta`: **[Optional]** Metadata object containing:
424
+ - `requirements`: _(User-maintained)_ List of package dependencies needed to run the code example
425
+ - For Python: PyPI packages (e.g., `["httpx", "openai"]`)
426
+ - For JavaScript: npm packages (e.g., `["node-fetch"]`)
427
+ - For Shell scripts: commands (e.g., `["curl"]`)
428
+ - Helps users understand what to install before running the example
429
+ - `expect`: _(User-maintained, strongly recommended)_ Expected substring that should appear in stdout when the test passes
430
+ - Examples:
431
+ - `"✓ Test passed"` - Explicit success message
432
+ - `"\"choices\""` - Check for JSON field in API response
433
+ - `"Status: 200"` - Check for HTTP status
434
+ - Without this field, tests only check exit code (0 = pass, non-zero = fail), which is unreliable
435
+ - `output`: _(System-maintained)_ Automatically populated by `usvc test run`
436
+ - Contains stdout from the last successful test execution
437
+ - Saved to `{listing_stem}_{code_filename}.out` file during test run
438
+ - Embedded into `meta.output` during `usvc publish`
439
+ - Displayed alongside code examples in your service listing
412
440
 
413
441
  ### Step 5: Validate and Test Before Publishing
414
442
 
@@ -500,8 +528,14 @@ curl ${API_ENDPOINT}/chat/completions \
500
528
 
501
529
  ```json
502
530
  {
531
+ "category": "code_examples",
532
+ "title": "Shell Example",
503
533
  "file_path": "test.sh.j2",
504
- "expect": "\"choices\""
534
+ "mime_type": "bash",
535
+ "is_public": true,
536
+ "meta": {
537
+ "expect": "\"choices\""
538
+ }
505
539
  }
506
540
  ```
507
541
 
@@ -532,9 +566,15 @@ if "choices" in data:
532
566
 
533
567
  ```json
534
568
  {
569
+ "category": "code_examples",
570
+ "title": "Python Example",
535
571
  "file_path": "test.py.j2",
536
- "expect": "✓ Validation passed",
537
- "requirements": ["httpx"]
572
+ "mime_type": "python",
573
+ "is_public": true,
574
+ "meta": {
575
+ "requirements": ["httpx"],
576
+ "expect": "✓ Validation passed"
577
+ }
538
578
  }
539
579
  ```
540
580
 
@@ -568,8 +608,14 @@ if (data.choices) {
568
608
 
569
609
  ```json
570
610
  {
611
+ "category": "code_examples",
612
+ "title": "JavaScript Example",
571
613
  "file_path": "test.js.j2",
572
- "expect": "✓ Success"
614
+ "mime_type": "javascript",
615
+ "is_public": true,
616
+ "meta": {
617
+ "expect": "✓ Success"
618
+ }
573
619
  }
574
620
  ```
575
621
 
@@ -625,12 +671,12 @@ If a field might not exist, use Jinja2 defaults:
625
671
  ## Tips for Effective Code Examples
626
672
 
627
673
  1. **Keep examples short and focused** - Test one thing at a time
628
- 2. **Use `expect` field** - Makes validation automatic and reliable
674
+ 2. **Use `meta.expect` field** - Makes validation automatic and reliable
629
675
  3. **Print clear success messages** - Makes debugging easier
630
676
  4. **Handle errors gracefully** - Exit with non-zero code on failure
631
677
  5. **Test locally first** - Always verify with hardcoded values before templating
632
678
  6. **Use meaningful output** - Print enough info to understand what happened
633
- 7. **Add requirements** - List all dependencies in the `requirements` field
679
+ 7. **Add requirements** - List all dependencies in `meta.requirements` field
634
680
 
635
681
  ## Workflow Summary
636
682
 
@@ -648,21 +694,89 @@ python3 test.py
648
694
  mv test.py test.py.j2
649
695
  vim test.py.j2 # Replace with {{ offering.name }}, etc.
650
696
 
651
- # 4. Add to listing.json
652
- vim listing.json # Add document entry
697
+ # 4. Add to listing.json with meta fields
698
+ vim listing.json # Add document entry with meta.requirements and meta.expect
653
699
 
654
700
  # 5. Validate and test
655
701
  usvc validate
656
702
  usvc test list
657
703
  usvc test run --provider your-provider
704
+ # ✓ Successful tests create .out files (e.g., listing_test.py.out in listing directory)
658
705
 
659
706
  # 6. Debug if needed
660
- cat failed_* # Check saved test files
707
+ cat failed_* # Check saved test files (in current directory)
708
+ cat services/*/listing_*.out # Review successful test outputs (in listing directories)
661
709
 
662
- # 7. Publish when tests pass
710
+ # 7. Publish - embeds .out files into meta.output
663
711
  usvc publish
712
+ # ✓ Reads .out files from listing directories and adds content to meta.output
713
+ # ✓ Output will appear alongside code examples in your service listing
664
714
  ```
665
715
 
716
+ ## Understanding meta.output Workflow
717
+
718
+ The `meta.output` field follows an automated workflow from test execution to publication:
719
+
720
+ ### 1. Testing Phase: `usvc test run`
721
+
722
+ When you run tests, successful executions generate `.out` files:
723
+
724
+ ```bash
725
+ $ usvc test run --provider fireworks
726
+
727
+ Testing: llama-3-1-405b - Python code example
728
+ ✓ Success (exit code: 0)
729
+ → Output saved to: /path/to/fireworks/services/llama-3-1-405b/listing_test.py.out
730
+ ```
731
+
732
+ **Output file naming:** `{listing_stem}_{code_filename}.out`
733
+
734
+ - `listing_stem`: The listing filename without extension (e.g., "listing" from "listing.json")
735
+ - `code_filename`: The code filename after template expansion (e.g., "test.py" from "test.py.j2")
736
+ - Example: `listing_test.py.out`, `svclisting_example.sh.out`
737
+
738
+ **File location:** Same directory as the listing file that references the code example
739
+
740
+ ### 2. Publishing Phase: `usvc publish`
741
+
742
+ During publish, the SDK automatically:
743
+
744
+ 1. Expands `.j2` templates for each model if a template is used
745
+ 2. Looks for matching `.out` files in the listing's base directory
746
+ 3. Reads the output content and embeds it into `meta.output`
747
+
748
+ **Example published document:**
749
+
750
+ ```json
751
+ {
752
+ "category": "code_examples",
753
+ "title": "Python code example",
754
+ "file_path": "chat-completion.py",
755
+ "file_content": "#!/usr/bin/env python3\nimport httpx...",
756
+ "mime_type": "python",
757
+ "meta": {
758
+ "requirements": ["httpx"],
759
+ "expect": "✓ Test passed",
760
+ "output": "{'id': 'chatcmpl-...', 'choices': [{'message': {'content': 'Hello!'}}]}\n✓ Test passed"
761
+ }
762
+ }
763
+ ```
764
+
765
+ ### 3. Display in Service Listing
766
+
767
+ After publishing, the output will automatically appear alongside the code example in your service listing documentation, allowing users to see both the code and its expected output together.
768
+
769
+ ### 4. Key Points
770
+
771
+ - **`.out` files are model-specific**: Since templates expand per model, each model gets its own output file
772
+ - **`.out` files location**: Saved in the **same directory as the listing file**, making them easy to find and version control
773
+ - **`.out` file naming**: Format is `{listing_stem}_{code_filename}.out` to clearly associate output with both listing and code
774
+ - **Version control**: You **can** commit `.out` files to version control since they're co-located with listings
775
+ - **Publishing is flexible**: `usvc publish` works even if `.out` files are missing (gracefully skips)
776
+ - **User vs System fields**:
777
+ - `meta.requirements` and `meta.expect` are **user-maintained** (you write these)
778
+ - `meta.output` is **system-maintained** (auto-generated by `usvc test run` and `usvc publish`)
779
+
666
780
  ## Interpreter Detection
667
781
 
668
782
  The test framework automatically detects the appropriate interpreter based on file extension:
@@ -710,7 +824,7 @@ If the required interpreter is not found, the test will fail with a clear error
710
824
 
711
825
  **Problem:** Exit code is 0 but test still fails
712
826
 
713
- **Solution:** Check the `expect` field - test requires the expected string to appear in stdout
827
+ **Solution:** Check the `meta.expect` field - test requires the expected string to appear in stdout
714
828
 
715
829
  ### Validation Errors
716
830
 
@@ -114,6 +114,148 @@ display_name = "My Service"
114
114
  description = "A high-performance digital service"
115
115
  ```
116
116
 
117
+ ## Override Files
118
+
119
+ Override files provide a way to complement and customize data files without modifying the base files. This is particularly useful when base files are auto-generated by scripts, and you need to manually curate specific values like status, logo URLs, or other metadata.
120
+
121
+ ### Naming Pattern
122
+
123
+ Override files follow the pattern: `<base_name>.override.<extension>`
124
+
125
+ Examples:
126
+ - `service.json` → `service.override.json`
127
+ - `provider.toml` → `provider.override.toml`
128
+ - `listing-premium.json` → `listing-premium.override.json`
129
+
130
+ ### How Override Files Work
131
+
132
+ When any data file is loaded by the SDK:
133
+
134
+ 1. The base file is loaded first
135
+ 2. The system checks for a corresponding `.override.*` file
136
+ 3. If found, the override file is loaded and **deep-merged** into the base data
137
+ 4. Override values take precedence over base values
138
+
139
+ This merge happens automatically and transparently - you don't need to do anything special.
140
+
141
+ ### Merge Behavior
142
+
143
+ **Nested Dictionaries**: Recursively merged - override values complement and replace base values
144
+
145
+ **Example**:
146
+ ```json
147
+ // Base: service.json
148
+ {
149
+ "name": "my-service",
150
+ "config": {
151
+ "host": "localhost",
152
+ "port": 8080,
153
+ "timeout": 30
154
+ }
155
+ }
156
+
157
+ // Override: service.override.json
158
+ {
159
+ "config": {
160
+ "port": 9000,
161
+ "ssl": true
162
+ },
163
+ "status": "active"
164
+ }
165
+
166
+ // Merged Result
167
+ {
168
+ "name": "my-service",
169
+ "config": {
170
+ "host": "localhost", // preserved from base
171
+ "port": 9000, // overridden
172
+ "timeout": 30, // preserved from base
173
+ "ssl": true // added from override
174
+ },
175
+ "status": "active" // added from override
176
+ }
177
+ ```
178
+
179
+ **Lists and Primitives**: Completely replaced (not merged)
180
+
181
+ ```json
182
+ // Base: service.json
183
+ {
184
+ "tags": ["python", "web", "api"],
185
+ "version": 1
186
+ }
187
+
188
+ // Override: service.override.json
189
+ {
190
+ "tags": ["backend", "production"]
191
+ }
192
+
193
+ // Merged Result
194
+ {
195
+ "tags": ["backend", "production"], // completely replaced
196
+ "version": 1
197
+ }
198
+ ```
199
+
200
+ This replacement behavior for lists is intentional and predictable - if you want to change a list, simply specify the complete desired list in the override file.
201
+
202
+ ### Common Use Cases
203
+
204
+ **1. Manual curation of auto-generated data**
205
+ ```json
206
+ // service.json (auto-generated by script)
207
+ {
208
+ "schema": "service_v1",
209
+ "name": "gpt-4",
210
+ "description": "Auto-generated description",
211
+ "status": "draft"
212
+ }
213
+
214
+ // service.override.json (manually maintained)
215
+ {
216
+ "status": "active",
217
+ "logo_url": "https://example.com/custom-logo.png",
218
+ "featured": true
219
+ }
220
+ ```
221
+
222
+ **2. Environment-specific configuration**
223
+ ```toml
224
+ # provider.toml (base configuration)
225
+ schema = "provider_v1"
226
+ name = "my-provider"
227
+ api_endpoint = "https://api.example.com"
228
+
229
+ # provider.override.toml (local testing overrides)
230
+ api_endpoint = "http://localhost:8000"
231
+ debug_mode = true
232
+ ```
233
+
234
+ **3. Maintaining custom metadata**
235
+ ```json
236
+ // listing-premium.json (generated from template)
237
+ {
238
+ "schema": "listing_v1",
239
+ "name": "premium-tier",
240
+ "pricing": { /* auto-generated */ }
241
+ }
242
+
243
+ // listing-premium.override.json (manual customization)
244
+ {
245
+ "featured": true,
246
+ "promotional_badge": "Best Value",
247
+ "custom_cta": "Try Premium Now!"
248
+ }
249
+ ```
250
+
251
+ ### Version Control
252
+
253
+ Override files should be committed to version control alongside base files. They are part of your data and represent intentional manual customizations that should be preserved and tracked.
254
+
255
+ ### File Location
256
+
257
+ Override files must be in the same directory as their corresponding base files, following the directory structure rules described above.
258
+
117
259
  ## Schema Requirements
118
260
 
119
261
  Each file must include a `schema` field identifying its type:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unitysvc-services"
3
- version = "0.1.5"
3
+ version = "0.1.6"
4
4
  description = "SDK for digital service providers on UnitySVC"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Bo Peng", email = "bo.peng@unitysvc.com" }]
@@ -255,18 +255,6 @@ class Document(BaseModel):
255
255
  default=False,
256
256
  description="Whether document is publicly accessible without authentication",
257
257
  )
258
- requirements: list[str] | None = Field(
259
- default=None,
260
- description="Required packages/modules for running this code example (e.g., ['openai', 'httpx'])",
261
- )
262
- expect: str | None = Field(
263
- default=None,
264
- max_length=500,
265
- description=(
266
- "Expected output substring for code example validation. "
267
- "If specified, test passes only if stdout contains this string."
268
- ),
269
- )
270
258
 
271
259
 
272
260
  class RateLimit(BaseModel):
@@ -67,6 +67,7 @@ class ServiceDataPublisher(UnitySvcAPI):
67
67
  offering: dict[str, Any] | None = None,
68
68
  provider: dict[str, Any] | None = None,
69
69
  seller: dict[str, Any] | None = None,
70
+ listing_filename: str | None = None,
70
71
  ) -> dict[str, Any]:
71
72
  """Recursively resolve file references and include content in data.
72
73
 
@@ -80,6 +81,7 @@ class ServiceDataPublisher(UnitySvcAPI):
80
81
  offering: Offering data for template rendering (optional)
81
82
  provider: Provider data for template rendering (optional)
82
83
  seller: Seller data for template rendering (optional)
84
+ listing_filename: Listing filename for constructing output filenames (optional)
83
85
 
84
86
  Returns:
85
87
  Data with file references resolved and content loaded
@@ -90,14 +92,26 @@ class ServiceDataPublisher(UnitySvcAPI):
90
92
  if isinstance(value, dict):
91
93
  # Recursively process nested dictionaries
92
94
  result[key] = self.resolve_file_references(
93
- value, base_path, listing=listing, offering=offering, provider=provider, seller=seller
95
+ value,
96
+ base_path,
97
+ listing=listing,
98
+ offering=offering,
99
+ provider=provider,
100
+ seller=seller,
101
+ listing_filename=listing_filename,
94
102
  )
95
103
  elif isinstance(value, list):
96
104
  # Process lists
97
105
  result[key] = [
98
106
  (
99
107
  self.resolve_file_references(
100
- item, base_path, listing=listing, offering=offering, provider=provider, seller=seller
108
+ item,
109
+ base_path,
110
+ listing=listing,
111
+ offering=offering,
112
+ provider=provider,
113
+ seller=seller,
114
+ listing_filename=listing_filename,
101
115
  )
102
116
  if isinstance(item, dict)
103
117
  else item
@@ -135,6 +149,41 @@ class ServiceDataPublisher(UnitySvcAPI):
135
149
  else:
136
150
  result[key] = value
137
151
 
152
+ # After processing all fields, check if this is a code_examples document
153
+ # If so, try to load corresponding .out file and add to meta.output
154
+ if result.get("category") == "code_examples" and result.get("file_content") and listing_filename:
155
+ # Get the actual filename (after .j2 stripping if applicable)
156
+ # If file_path was updated (e.g., from "test.py.j2" to "test.py"), use that
157
+ # Otherwise, extract basename from original file_path
158
+ output_base_filename: str | None = None
159
+
160
+ # Check if file_path was modified (original might have had .j2)
161
+ file_path_value = result.get("file_path", "")
162
+ if file_path_value:
163
+ output_base_filename = Path(file_path_value).name
164
+
165
+ if output_base_filename:
166
+ # Construct output filename: {listing_stem}_{output_base_filename}.out
167
+ # e.g., "svclisting_test.py.out" for svclisting.json and test.py
168
+ listing_stem = Path(listing_filename).stem
169
+ output_filename = f"{listing_stem}_{output_base_filename}.out"
170
+
171
+ # Try to find the .out file in base_path (listing directory)
172
+ output_path = base_path / output_filename
173
+
174
+ if output_path.exists():
175
+ try:
176
+ with open(output_path, encoding="utf-8") as f:
177
+ output_content = f.read()
178
+
179
+ # Add output to meta field
180
+ if "meta" not in result or result["meta"] is None:
181
+ result["meta"] = {}
182
+ result["meta"]["output"] = output_content
183
+ except Exception:
184
+ # Don't fail if output file can't be read, just skip it
185
+ pass
186
+
138
187
  return result
139
188
 
140
189
  async def post( # type: ignore[override]
@@ -401,6 +450,7 @@ class ServiceDataPublisher(UnitySvcAPI):
401
450
  offering=service_data,
402
451
  provider=provider_data,
403
452
  seller=seller_data,
453
+ listing_filename=listing_file.name,
404
454
  )
405
455
 
406
456
  # Post to the endpoint using retry helper
@@ -79,6 +79,9 @@ def extract_code_examples_from_listing(listing_data: dict[str, Any], listing_fil
79
79
  # Resolve relative path
80
80
  absolute_path = (listing_file.parent / file_path).resolve()
81
81
 
82
+ # Extract meta fields for code examples (expect, requirements, etc.)
83
+ meta = doc.get("meta", {}) or {}
84
+
82
85
  code_example = {
83
86
  "service_name": service_name,
84
87
  "title": doc.get("title", "Untitled"),
@@ -86,7 +89,8 @@ def extract_code_examples_from_listing(listing_data: dict[str, Any], listing_fil
86
89
  "file_path": str(absolute_path),
87
90
  "listing_data": listing_data, # Full listing data for templates
88
91
  "listing_file": listing_file, # Path to listing file for loading related data
89
- "expect": doc.get("expect"), # Expected output substring for validation
92
+ "expect": meta.get("expect"), # Expected output substring for validation (from meta)
93
+ "requirements": meta.get("requirements"), # Required packages (from meta)
90
94
  }
91
95
  code_examples.append(code_example)
92
96
 
@@ -200,6 +204,8 @@ def execute_code_example(code_example: dict[str, Any], credentials: dict[str, st
200
204
  "stderr": None,
201
205
  "rendered_content": None,
202
206
  "file_suffix": None,
207
+ "listing_file": None,
208
+ "actual_filename": None,
203
209
  }
204
210
 
205
211
  file_path = code_example.get("file_path")
@@ -237,6 +243,8 @@ def execute_code_example(code_example: dict[str, Any], credentials: dict[str, st
237
243
  # Store rendered content and file suffix for later use (e.g., writing failed tests)
238
244
  result["rendered_content"] = file_content
239
245
  result["file_suffix"] = file_suffix
246
+ result["listing_file"] = listing_file
247
+ result["actual_filename"] = actual_filename
240
248
 
241
249
  # Determine interpreter to use
242
250
  lines = file_content.split("\n")
@@ -686,6 +694,27 @@ def run(
686
694
  console.print(f" [green]✓ Success[/green] (exit code: {result['exit_code']})")
687
695
  if verbose and result["stdout"]:
688
696
  console.print(f" [dim]stdout:[/dim] {result['stdout'][:200]}")
697
+
698
+ # Save successful test output to .out file
699
+ if result.get("stdout") and result.get("listing_file") and result.get("actual_filename"):
700
+ listing_file = Path(result["listing_file"])
701
+ actual_filename = result["actual_filename"]
702
+
703
+ # Create filename: {listing_stem}_{actual_filename}.out
704
+ # e.g., "svclisting_test.py.out" for svclisting.json and test.py
705
+ listing_stem = listing_file.stem
706
+ output_filename = f"{listing_stem}_{actual_filename}.out"
707
+
708
+ # Save to listing directory
709
+ output_path = listing_file.parent / output_filename
710
+
711
+ # Write stdout to .out file
712
+ try:
713
+ with open(output_path, "w", encoding="utf-8") as f:
714
+ f.write(result["stdout"])
715
+ console.print(f" [dim]→ Output saved to:[/dim] {output_path}")
716
+ except Exception as e:
717
+ console.print(f" [yellow]⚠ Failed to save output: {e}[/yellow]")
689
718
  else:
690
719
  console.print(f" [red]✗ Failed[/red] - {result['error']}")
691
720
  if verbose:
@@ -695,15 +724,13 @@ def run(
695
724
  console.print(f" [dim]stderr:[/dim] {result['stderr'][:200]}")
696
725
 
697
726
  # Write failed test content to current directory
698
- if result.get("rendered_content"):
699
- # Create safe filename: failed_<service_name>_<title><ext>
700
- # Sanitize service name and title for filename
701
- safe_service = service_name.replace("/", "_").replace(" ", "_")
702
- safe_title = title.replace("/", "_").replace(" ", "_")
703
- file_suffix = result.get("file_suffix", ".txt")
704
-
705
- # Create filename
706
- failed_filename = f"failed_{safe_service}_{safe_title}{file_suffix}"
727
+ if result.get("rendered_content") and result.get("listing_file") and result.get("actual_filename"):
728
+ listing_file = Path(result["listing_file"])
729
+ actual_filename = result["actual_filename"]
730
+ listing_stem = listing_file.stem
731
+
732
+ # Create filename: failed_{listing_stem}_{actual_filename}
733
+ failed_filename = f"failed_{listing_stem}_{actual_filename}"
707
734
 
708
735
  # Prepare content with environment variables as header comments
709
736
  content_with_env = result["rendered_content"]
@@ -10,10 +10,47 @@ import tomli_w
10
10
  from jinja2 import Template
11
11
 
12
12
 
13
+ def deep_merge_dicts(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
14
+ """
15
+ Deep merge two dictionaries, with override values taking precedence.
16
+
17
+ For nested dictionaries, performs recursive merge. For all other types
18
+ (lists, primitives), the override value completely replaces the base value.
19
+
20
+ Args:
21
+ base: Base dictionary
22
+ override: Override dictionary (values take precedence)
23
+
24
+ Returns:
25
+ Merged dictionary
26
+ """
27
+ result = base.copy()
28
+
29
+ for key, value in override.items():
30
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
31
+ # Recursively merge nested dictionaries
32
+ result[key] = deep_merge_dicts(result[key], value)
33
+ else:
34
+ # For all other types (lists, primitives, etc.), override completely
35
+ result[key] = value
36
+
37
+ return result
38
+
39
+
13
40
  def load_data_file(file_path: Path) -> tuple[dict[str, Any], str]:
14
41
  """
15
42
  Load a data file (JSON or TOML) and return (data, format).
16
43
 
44
+ Automatically checks for and merges override files with the pattern:
45
+ <base_name>.override.<extension>
46
+
47
+ For example:
48
+ - service.json -> service.override.json
49
+ - provider.toml -> provider.override.toml
50
+
51
+ If an override file exists, it will be deep-merged with the base file,
52
+ with override values taking precedence.
53
+
17
54
  Args:
18
55
  file_path: Path to the data file
19
56
 
@@ -23,15 +60,41 @@ def load_data_file(file_path: Path) -> tuple[dict[str, Any], str]:
23
60
  Raises:
24
61
  ValueError: If file format is not supported
25
62
  """
63
+ # Load the base file
26
64
  if file_path.suffix == ".json":
27
65
  with open(file_path, encoding="utf-8") as f:
28
- return json.load(f), "json"
66
+ data = json.load(f)
67
+ file_format = "json"
29
68
  elif file_path.suffix == ".toml":
30
69
  with open(file_path, "rb") as f:
31
- return tomllib.load(f), "toml"
70
+ data = tomllib.load(f)
71
+ file_format = "toml"
32
72
  else:
33
73
  raise ValueError(f"Unsupported file format: {file_path.suffix}")
34
74
 
75
+ # Check for override file
76
+ # Pattern: <stem>.override.<suffix>
77
+ # Example: service.json -> service.override.json
78
+ override_path = file_path.with_stem(f"{file_path.stem}.override")
79
+
80
+ if override_path.exists():
81
+ # Load the override file (same format as base file)
82
+ if override_path.suffix == ".json":
83
+ with open(override_path, encoding="utf-8") as f:
84
+ override_data = json.load(f)
85
+ elif override_path.suffix == ".toml":
86
+ with open(override_path, "rb") as f:
87
+ override_data = tomllib.load(f)
88
+ else:
89
+ # This shouldn't happen since we're using the same suffix as base
90
+ # But handle it gracefully
91
+ override_data = {}
92
+
93
+ # Deep merge the override data into the base data
94
+ data = deep_merge_dicts(data, override_data)
95
+
96
+ return data, file_format
97
+
35
98
 
36
99
  def write_data_file(file_path: Path, data: dict[str, Any], format: str) -> None:
37
100
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unitysvc-services
3
- Version: 0.1.5
3
+ Version: 0.1.6
4
4
  Summary: SDK for digital service providers on UnitySVC
5
5
  Author-email: Bo Peng <bo.peng@unitysvc.com>
6
6
  Maintainer-email: Bo Peng <bo.peng@unitysvc.com>
@@ -6,6 +6,8 @@ import pytest
6
6
 
7
7
  from unitysvc_services.utils import (
8
8
  convert_convenience_fields_to_documents,
9
+ deep_merge_dicts,
10
+ load_data_file,
9
11
  resolve_provider_name,
10
12
  resolve_service_name_for_listing,
11
13
  )
@@ -221,3 +223,179 @@ def test_convert_mime_type_detection(tmp_path: Path) -> None:
221
223
  data = {"logo": file_path}
222
224
  result = convert_convenience_fields_to_documents(data, tmp_path, terms_field=None)
223
225
  assert result["documents"][0]["mime_type"] == expected_mime
226
+
227
+
228
+ def test_deep_merge_dicts_simple() -> None:
229
+ """Test simple dictionary merge."""
230
+ base = {"a": 1, "b": 2, "c": 3}
231
+ override = {"b": 20, "d": 4}
232
+
233
+ result = deep_merge_dicts(base, override)
234
+
235
+ assert result == {"a": 1, "b": 20, "c": 3, "d": 4}
236
+
237
+
238
+ def test_deep_merge_dicts_nested() -> None:
239
+ """Test deep merge with nested dictionaries."""
240
+ base = {"config": {"host": "localhost", "port": 8080}, "name": "service1"}
241
+ override = {"config": {"port": 9000, "ssl": True}, "status": "active"}
242
+
243
+ result = deep_merge_dicts(base, override)
244
+
245
+ assert result == {
246
+ "config": {"host": "localhost", "port": 9000, "ssl": True},
247
+ "name": "service1",
248
+ "status": "active",
249
+ }
250
+
251
+
252
+ def test_deep_merge_dicts_lists_replaced() -> None:
253
+ """Test that lists are replaced, not merged."""
254
+ base = {"tags": ["python", "web"], "name": "service1"}
255
+ override = {"tags": ["backend"]}
256
+
257
+ result = deep_merge_dicts(base, override)
258
+
259
+ # Lists should be replaced, not merged
260
+ assert result == {"tags": ["backend"], "name": "service1"}
261
+
262
+
263
+ def test_deep_merge_dicts_empty_override() -> None:
264
+ """Test merge with empty override."""
265
+ base = {"a": 1, "b": 2}
266
+ override: dict[str, int] = {}
267
+
268
+ result = deep_merge_dicts(base, override)
269
+
270
+ assert result == {"a": 1, "b": 2}
271
+
272
+
273
+ def test_deep_merge_dicts_deeply_nested() -> None:
274
+ """Test deeply nested dictionary merge."""
275
+ base = {"level1": {"level2": {"level3": {"value": "old", "keep": True}}}}
276
+ override = {"level1": {"level2": {"level3": {"value": "new"}}}}
277
+
278
+ result = deep_merge_dicts(base, override)
279
+
280
+ assert result == {"level1": {"level2": {"level3": {"value": "new", "keep": True}}}}
281
+
282
+
283
+ def test_load_data_file_json_no_override(tmp_path: Path) -> None:
284
+ """Test loading JSON file without override."""
285
+ import json
286
+
287
+ # Create a base JSON file
288
+ base_file = tmp_path / "test.json"
289
+ base_data = {"schema": "test_v1", "name": "test", "value": 100}
290
+ with open(base_file, "w", encoding="utf-8") as f:
291
+ json.dump(base_data, f)
292
+
293
+ data, file_format = load_data_file(base_file)
294
+
295
+ assert file_format == "json"
296
+ assert data == base_data
297
+
298
+
299
+ def test_load_data_file_json_with_override(tmp_path: Path) -> None:
300
+ """Test loading JSON file with override."""
301
+ import json
302
+
303
+ # Create base JSON file
304
+ base_file = tmp_path / "service.json"
305
+ base_data = {"schema": "service_v1", "name": "my-service", "status": "draft", "version": 1}
306
+ with open(base_file, "w", encoding="utf-8") as f:
307
+ json.dump(base_data, f)
308
+
309
+ # Create override JSON file
310
+ override_file = tmp_path / "service.override.json"
311
+ override_data = {"status": "active", "logo_url": "https://example.com/logo.png"}
312
+ with open(override_file, "w", encoding="utf-8") as f:
313
+ json.dump(override_data, f)
314
+
315
+ data, file_format = load_data_file(base_file)
316
+
317
+ assert file_format == "json"
318
+ assert data == {
319
+ "schema": "service_v1",
320
+ "name": "my-service",
321
+ "status": "active", # overridden
322
+ "version": 1,
323
+ "logo_url": "https://example.com/logo.png", # added from override
324
+ }
325
+
326
+
327
+ def test_load_data_file_toml_no_override(tmp_path: Path) -> None:
328
+ """Test loading TOML file without override."""
329
+ import tomli_w
330
+
331
+ # Create a base TOML file
332
+ base_file = tmp_path / "test.toml"
333
+ base_data = {"schema": "test_v1", "name": "test", "value": 100}
334
+ with open(base_file, "wb") as f:
335
+ tomli_w.dump(base_data, f)
336
+
337
+ data, file_format = load_data_file(base_file)
338
+
339
+ assert file_format == "toml"
340
+ assert data == base_data
341
+
342
+
343
+ def test_load_data_file_toml_with_override(tmp_path: Path) -> None:
344
+ """Test loading TOML file with override."""
345
+ import tomli_w
346
+
347
+ # Create base TOML file
348
+ base_file = tmp_path / "provider.toml"
349
+ base_data = {"schema": "provider_v1", "name": "my-provider", "tier": "free"}
350
+ with open(base_file, "wb") as f:
351
+ tomli_w.dump(base_data, f)
352
+
353
+ # Create override TOML file
354
+ override_file = tmp_path / "provider.override.toml"
355
+ override_data = {"tier": "premium", "featured": True}
356
+ with open(override_file, "wb") as f:
357
+ tomli_w.dump(override_data, f)
358
+
359
+ data, file_format = load_data_file(base_file)
360
+
361
+ assert file_format == "toml"
362
+ assert data == {
363
+ "schema": "provider_v1",
364
+ "name": "my-provider",
365
+ "tier": "premium", # overridden
366
+ "featured": True, # added from override
367
+ }
368
+
369
+
370
+ def test_load_data_file_with_nested_override(tmp_path: Path) -> None:
371
+ """Test loading file with nested dictionary override."""
372
+ import json
373
+
374
+ # Create base file with nested config
375
+ base_file = tmp_path / "config.json"
376
+ base_data = {
377
+ "schema": "config_v1",
378
+ "database": {"host": "localhost", "port": 5432, "name": "testdb"},
379
+ "cache": {"enabled": False},
380
+ }
381
+ with open(base_file, "w", encoding="utf-8") as f:
382
+ json.dump(base_data, f)
383
+
384
+ # Create override file with partial nested override
385
+ override_file = tmp_path / "config.override.json"
386
+ override_data = {"database": {"port": 3306, "ssl": True}, "cache": {"enabled": True}}
387
+ with open(override_file, "w", encoding="utf-8") as f:
388
+ json.dump(override_data, f)
389
+
390
+ data, file_format = load_data_file(base_file)
391
+
392
+ assert data == {
393
+ "schema": "config_v1",
394
+ "database": {
395
+ "host": "localhost", # preserved from base
396
+ "port": 3306, # overridden
397
+ "name": "testdb", # preserved from base
398
+ "ssl": True, # added from override
399
+ },
400
+ "cache": {"enabled": True}, # overridden
401
+ }