unitysvc-core 0.2.35__tar.gz → 0.2.36__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.
- unitysvc_core-0.2.36/PKG-INFO +224 -0
- unitysvc_core-0.2.36/README.md +189 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/pyproject.toml +2 -2
- unitysvc_core-0.2.36/src/unitysvc_core/grants.py +375 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core/models/__init__.py +2 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core/models/listing_data.py +40 -1
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core/models/offering_data.py +13 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core/models/validators.py +72 -1
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core/schema/listing_v1.json +16 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core/schema/offering_v1.json +14 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core/validator.py +18 -1
- unitysvc_core-0.2.36/src/unitysvc_core.egg-info/PKG-INFO +224 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core.egg-info/SOURCES.txt +4 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core.egg-info/requires.txt +1 -1
- unitysvc_core-0.2.36/tests/test_canonical_interfaces.py +69 -0
- unitysvc_core-0.2.36/tests/test_grants.py +313 -0
- unitysvc_core-0.2.36/tests/test_listing_audience.py +52 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/tests/test_validator.py +78 -0
- unitysvc_core-0.2.35/PKG-INFO +0 -151
- unitysvc_core-0.2.35/README.md +0 -116
- unitysvc_core-0.2.35/src/unitysvc_core.egg-info/PKG-INFO +0 -151
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/LICENSE +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/setup.cfg +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core/__init__.py +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core/models/base.py +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core/models/documents.py +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core/models/listing_v1.py +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core/models/offering_v1.py +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core/models/pricing.py +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core/models/promotion_data.py +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core/models/provider_data.py +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core/models/provider_v1.py +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core/models/service.py +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core/models/service_group_data.py +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core/parameter_usage.py +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core/py.typed +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core/schema/base.json +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core/schema/promotion_v1.json +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core/schema/provider_v1.json +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core/schema/service_group_v1.json +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core/utils.py +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core.egg-info/dependency_links.txt +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/src/unitysvc_core.egg-info/top_level.txt +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/tests/test_access_interface_routing.py +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/tests/test_effective_price.py +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/tests/test_hidden_path_skip.py +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/tests/test_offering_data.py +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/tests/test_parameter_usage.py +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/tests/test_pricing.py +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/tests/test_provider_account_rate_limits.py +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/tests/test_service_group.py +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/tests/test_service_status.py +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/tests/test_utils.py +0 -0
- {unitysvc_core-0.2.35 → unitysvc_core-0.2.36}/tests/test_validators.py +0 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: unitysvc-core
|
|
3
|
+
Version: 0.2.36
|
|
4
|
+
Summary: Shared data models and validation helpers for UnitySVC packages
|
|
5
|
+
Author-email: Bo Peng <bo.peng@unitysvc.com>
|
|
6
|
+
Maintainer-email: Bo Peng <bo.peng@unitysvc.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: bugs, https://github.com/unitysvc/unitysvc-core/issues
|
|
9
|
+
Project-URL: homepage, https://github.com/unitysvc/unitysvc-core
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: pydantic>=2
|
|
18
|
+
Requires-Dist: email-validator
|
|
19
|
+
Requires-Dist: jsonschema>=4.18
|
|
20
|
+
Requires-Dist: jinja2
|
|
21
|
+
Requires-Dist: tomli-w
|
|
22
|
+
Requires-Dist: unitysvc-data>=0.1.9
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: coverage; extra == "test"
|
|
25
|
+
Requires-Dist: pytest; extra == "test"
|
|
26
|
+
Requires-Dist: ruff; extra == "test"
|
|
27
|
+
Requires-Dist: mypy; extra == "test"
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: coverage; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest; extra == "dev"
|
|
31
|
+
Requires-Dist: ruff; extra == "dev"
|
|
32
|
+
Requires-Dist: ty; extra == "dev"
|
|
33
|
+
Requires-Dist: ipdb; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# unitysvc-core
|
|
37
|
+
|
|
38
|
+
Shared data models, JSON schemas, and validation helpers for the UnitySVC
|
|
39
|
+
ecosystem. This package is **audience-neutral**: it contains only the
|
|
40
|
+
foundational types and helpers that are consumed by the UnitySVC backend,
|
|
41
|
+
the customer SDK, the admin CLI, and the seller SDK.
|
|
42
|
+
|
|
43
|
+
It intentionally does **not** include any CLI, HTTP client, or
|
|
44
|
+
audience-specific helpers. Those live in the corresponding packages:
|
|
45
|
+
|
|
46
|
+
- `unitysvc-sellers` — seller SDK + `usvc_seller` CLI, catalog builders
|
|
47
|
+
(`populate_from_iterator`, `render_template_file`,
|
|
48
|
+
`convert_convenience_fields_to_documents`, etc.)
|
|
49
|
+
- `unitysvc` — customer SDK + `usvc` CLI
|
|
50
|
+
- `unitysvc-admin` — admin CLI
|
|
51
|
+
|
|
52
|
+
## What's inside
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
src/unitysvc_core/
|
|
56
|
+
├── models/
|
|
57
|
+
│ ├── base.py # All enums (status, type, currency, rate-limit, etc.)
|
|
58
|
+
│ ├── pricing.py # PriceStr, UsageData, all *PriceData classes,
|
|
59
|
+
│ │ # Pricing union, validate_pricing, cost calculation
|
|
60
|
+
│ ├── documents.py # DocumentData
|
|
61
|
+
│ ├── service.py # RateLimit, ServiceConstraints, AccessInterfaceData,
|
|
62
|
+
│ │ # UpstreamAccessConfigData
|
|
63
|
+
│ ├── validators.py # validate_name, validate_service_options,
|
|
64
|
+
│ │ # suggest_valid_name, SUPPORTED_SERVICE_OPTIONS
|
|
65
|
+
│ ├── provider_v1.py # Provider file schema + ProviderData
|
|
66
|
+
│ ├── offering_v1.py # Offering file schema + ServiceOfferingData
|
|
67
|
+
│ ├── listing_v1.py # Listing file schema + ServiceListingData
|
|
68
|
+
│ ├── promotion_v1.py # Promotion file schema + PromotionData
|
|
69
|
+
│ └── service_group_v1.py
|
|
70
|
+
├── schema/ # Generated JSON schemas (run scripts/generate_schemas.py; CI enforces sync)
|
|
71
|
+
├── validator.py # DataValidator — per-file schema validation,
|
|
72
|
+
│ # union-field reference checks, API-key secret
|
|
73
|
+
│ # scanning, Jinja2 syntax validation, etc.
|
|
74
|
+
└── utils.py # Content hashing, mime/extension helpers,
|
|
75
|
+
# data file loaders (JSON/TOML + overrides),
|
|
76
|
+
# schema-based file discovery
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### What's deliberately NOT here
|
|
80
|
+
|
|
81
|
+
- **CLI code** — `typer`, `rich`, no entry points
|
|
82
|
+
- **HTTP clients** — no `httpx`, no API wrappers
|
|
83
|
+
- **Seller catalog builders** — `populate_from_iterator`,
|
|
84
|
+
`render_template_file`, `convert_convenience_fields_to_documents`,
|
|
85
|
+
`execute_script_content` live in `unitysvc-sellers`
|
|
86
|
+
- **Seller-catalog-layout validation** — `validate_provider_status`,
|
|
87
|
+
`validate_all_service_directories`, `resolve_provider_name`,
|
|
88
|
+
`resolve_service_name_for_listing` live in `unitysvc-sellers`
|
|
89
|
+
|
|
90
|
+
## Installation
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
pip install unitysvc-core
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Dependencies: `pydantic`, `email-validator`, `jsonschema`, `jinja2`,
|
|
97
|
+
`tomli-w`. No `typer`, no `rich`, no `httpx`.
|
|
98
|
+
|
|
99
|
+
## Usage
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from pathlib import Path
|
|
103
|
+
|
|
104
|
+
import unitysvc_core
|
|
105
|
+
from unitysvc_core.models import ProviderV1, OfferingV1, ListingV1
|
|
106
|
+
from unitysvc_core.models.pricing import UsageData, validate_pricing
|
|
107
|
+
from unitysvc_core.validator import DataValidator
|
|
108
|
+
|
|
109
|
+
# Validate a single catalog directory
|
|
110
|
+
validator = DataValidator(
|
|
111
|
+
data_dir=Path("./catalog"),
|
|
112
|
+
schema_dir=Path(unitysvc_core.__file__).parent / "schema",
|
|
113
|
+
)
|
|
114
|
+
results = validator.validate_all()
|
|
115
|
+
|
|
116
|
+
# Typed pricing + cost calculation
|
|
117
|
+
pricing = validate_pricing({
|
|
118
|
+
"type": "one_million_tokens",
|
|
119
|
+
"input": "0.50",
|
|
120
|
+
"output": "1.50",
|
|
121
|
+
})
|
|
122
|
+
cost = pricing.calculate_cost(UsageData(input_tokens=1_000, output_tokens=500))
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## History
|
|
126
|
+
|
|
127
|
+
This package was split out of `unitysvc-services` (see
|
|
128
|
+
[unitysvc/unitysvc-services#99](https://github.com/unitysvc/unitysvc-services/issues/99)).
|
|
129
|
+
The seller CLI, catalog builders, and seller-catalog-layout validators
|
|
130
|
+
moved to `unitysvc-sellers`.
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT
|
|
135
|
+
|
|
136
|
+
### Required enrollment parameter usage
|
|
137
|
+
|
|
138
|
+
The shared validator rejects required user parameters that no channel, interface,
|
|
139
|
+
or document consumes. Only those consumer fields count — the offering's
|
|
140
|
+
`upstream_access_config`, the listing's `user_access_interfaces`, and document
|
|
141
|
+
content (a `.j2` document's template source, which the seller validator reads from
|
|
142
|
+
disk as the SDK uploads it); metadata such as `display_name` or `description` is
|
|
143
|
+
never rendered with `params`. File validation checks a listing against its paired offering
|
|
144
|
+
when present; backend ingest calls the same `validate_required_parameter_usage`
|
|
145
|
+
helper with the complete listing and offering. Schema declarations alone are not
|
|
146
|
+
consumers. Jinja attribute access, string-key lookups, and `params.get("name")`
|
|
147
|
+
are inspected; dynamic access and whole-map iteration conservatively consume all
|
|
148
|
+
parameters. Deprecated content can still be retired.
|
|
149
|
+
|
|
150
|
+
This validates usage, not channel attribution: a document reference counts as a
|
|
151
|
+
consumer without proving that the intended paid channel takes enrollment.
|
|
152
|
+
|
|
153
|
+
### Canonical user access interfaces
|
|
154
|
+
|
|
155
|
+
A user access interface is the service's canonical address, the same for every caller, so `validate_listing_interfaces_are_canonical` refuses one that reads a per-enrollment value (`enrollment.*`, `params.*`). An enrollment is reached through the address the platform gives it: `/e/<CODE>` on the API gateway, which a listing never declares. The only per-enrollment form allowed is where the protocol has no such path and the code itself is the address: an SMTP `routing_key.username` or an S3 bucket segment that is exactly `{{ enrollment.code }}`. Values the same for every caller (`service_name`, `routing_vars`) stay allowed. The shared validator runs it on every `listing_v1`; backend ingest calls the same function (unitysvc/unitysvc#2726).
|
|
156
|
+
|
|
157
|
+
### Listing audience
|
|
158
|
+
|
|
159
|
+
A listing may say who the service is for and what a buyer must know before enrolling, as `audience`: a short list of plain-text phrases (unitysvc/unitysvc#2717):
|
|
160
|
+
|
|
161
|
+
```json
|
|
162
|
+
"audience": [
|
|
163
|
+
"Rice University students (enrollment at Rice required)",
|
|
164
|
+
"US customers only / export restrictions may apply"
|
|
165
|
+
]
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
At most 8 phrases, each 1-120 characters after trimming, no URLs or markup (details belong in the description or documents), and no phrase twice. The phrases are shown before enrollment and read by assistants matching services to customers; the platform never enforces them. `capabilities` says what a service does; `audience` says who it is for.
|
|
169
|
+
|
|
170
|
+
### Exports and customer access requests
|
|
171
|
+
|
|
172
|
+
Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it exposes), a seller's **`customer_access_requests`** (what the seller asks the enrolling customer to approve: using other services through that customer's account and authority), and the issued **key's policy** (what the customer actually approved). There is no central list of services. `unitysvc_core.grants` holds the formats and the checks; it does no I/O.
|
|
173
|
+
|
|
174
|
+
**Invariant.** Calls made under an approved request run under the enrolling customer's delegated identity, within the approved permissions, options and limits. Looking up a target's `exports` validates a request's *capabilities*; customer consent and key issuance establish its *authority*. A request grants nothing by itself.
|
|
175
|
+
|
|
176
|
+
**1. The `exports` format (the offering, top level).** How other services may use this one:
|
|
177
|
+
|
|
178
|
+
```json
|
|
179
|
+
"exports": {
|
|
180
|
+
"permissions": {"read": ["GET /records/*"], "write": ["POST /records", "PUT /records/*"]},
|
|
181
|
+
"options": {
|
|
182
|
+
"type": "object",
|
|
183
|
+
"properties": {
|
|
184
|
+
"log_level": {"type": "string", "enum": ["debug", "info", "warn", "error"], "default": "info",
|
|
185
|
+
"title": "Lowest level recorded"},
|
|
186
|
+
"max_size": {"type": "integer", "minimum": 1, "maximum": 10000}
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
"options_ui_schema": {"log_level": {"ui:widget": "radio"}}
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
- `invoke` is always exported and covers every route; it may not be redeclared. A service that declares nothing (most of them) exports `invoke` only, with no options, which `service:<name>` or `service:*` covers.
|
|
194
|
+
- `permissions`: a name → the routes it covers, each `"<METHOD> /<path>"` (`*` for any method, `*` globs in the path). Permissions are never inferred from HTTP methods.
|
|
195
|
+
- `options`: a JSON Schema (draft 7) object of the query arguments the service accepts as customizations, like an enrollment's `user_parameters_schema`, with an optional react-jsonschema-form `options_ui_schema`; the frontend renders it with the same form component. It covers limits only the service can apply (a message size). Restrictions so every value is one query argument: property names are lowercase letters, digits and `_`; each property is a `string`, `integer`, `number`, `boolean` or an `array` of strings, serialized as an OpenAPI form-style query parameter with arrays comma-joined (`style: form, explode: false`); options are optional (no `required`) and only declared properties are accepted; the schema is self-contained (no `$ref`), so validating against it never fetches anything. A property's `default` is what the service does when the option is not sent.
|
|
196
|
+
|
|
197
|
+
**2. A customer access request (`upstream_access_config.<channel>.customer_access_requests`), and the policy entry it becomes.** Any channel may declare its own list; each approved request becomes an entry (a grant) in the issued key's policy, in the same shape:
|
|
198
|
+
|
|
199
|
+
```json
|
|
200
|
+
"customer_access_requests": [{"object": "service:log", "permissions": ["invoke"], "required": true,
|
|
201
|
+
"justification": "Records each check you run",
|
|
202
|
+
"options": "log_level=warn",
|
|
203
|
+
"limits": {"count_per_day": 1000}}]
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
- `object` names one service (`service:<name>`, no wildcards). `enrollment:<CODE>` (one enrollment of an enrollable service) may be allowed later.
|
|
207
|
+
- `options` is a query string (`parse_options`): each name once, each value non-empty; with a catalog, each value is converted to its declared type and validated against the target's `options` schema.
|
|
208
|
+
- **Enrollment is where options and limits are set.** In a request, `options` and `limits` only pre-fill the enrollment form for convenience. When enrolling, the customer confirms and sets the options (a form rendered from the target's `options` schema) and the limits, and adds their own limits; the result is the issued key's policy.
|
|
209
|
+
- `limits` (`validate_limits`): `count_per_hour` / `count_per_day` / `count_per_month` / `count_total` (positive integer) and the same for `amount_` (finite positive decimal, as a number or a string). Limits are universal: the platform meters every call, so any service can be limited without exporting anything. The seller's limits are a pre-fill; the customer sets them at enrollment, and an enrollment's own limits (unitysvc/unitysvc#2687) use the same shape. They are counted per policy entry (the enrollment's calls to that service) from the usage stream (counters keyed by scope, metric and window bucket), and the gateway refuses calls once one is exceeded.
|
|
210
|
+
|
|
211
|
+
**3. How options reach the service.** No new channel: the upstream URL. When the gateway constructs the target's upstream URL for a call made under a policy entry, it appends that entry's `options` as query arguments, `?log_level=warn&max_size=2000`, the way query arguments already reach every upstream, `log://` included. Rules:
|
|
212
|
+
|
|
213
|
+
- The gateway first strips any query argument the client sent under one of the target's exported option names, then appends the policy's values. Today the client's arguments win on a name collision, so without this a caller could override its own policy.
|
|
214
|
+
- An option the policy does not set is not sent; the service applies its own default. A plain `service:*` key sends none.
|
|
215
|
+
- Enrollment parameters are unchanged and keep working alongside: `{{ params.X }}` still renders into the channel config, including a templated `base_url` query.
|
|
216
|
+
- The platform's own services are terminated in gateway stages before the upstream URI is built, so each stage reads its options from the request's arguments (`log_dispatch` drops entries below `log_level`).
|
|
217
|
+
- SMTP has no query string; options for pure-SMTP delivery are out of scope.
|
|
218
|
+
|
|
219
|
+
**4. Where exports are published.** In the public catalog bundle: every service row carries its `exports` exactly as declared (`null` when none). `GET /v1/catalog/bundle` returns the bundle's URL; a `service:<name>` is the row whose `name` is `<name>`.
|
|
220
|
+
|
|
221
|
+
**5. Validating `customer_access_requests`.** `validate_channel_access_requests(upstream_access_config, resolve)` checks every channel's requests. Limits and a default claim (`invoke`, no options) need no lookup. For any other claim, `resolve(name)` returns the target's `exports` from the catalog (`{}` when it declares none) or `None` when no such service is published; core parses it with `parse_exports` and checks the claimed permissions and the options (names, then values against its `options` schema) against it, refusing an unpublished target. Without a resolver only the shape is checked.
|
|
222
|
+
|
|
223
|
+
- `DataValidator(data_dir, schema_dir, exports_resolver=...)` runs both checks on every `offering_v1` (`exports` and each channel's `customer_access_requests`). The seller SDK passes a resolver that downloads the catalog bundle once and reads the target row's `exports`; offline it passes none and gets the shape checks.
|
|
224
|
+
- Backend ingest calls the same function with a resolver over the published services, so an unknown target, permission or option fails at upload.
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# unitysvc-core
|
|
2
|
+
|
|
3
|
+
Shared data models, JSON schemas, and validation helpers for the UnitySVC
|
|
4
|
+
ecosystem. This package is **audience-neutral**: it contains only the
|
|
5
|
+
foundational types and helpers that are consumed by the UnitySVC backend,
|
|
6
|
+
the customer SDK, the admin CLI, and the seller SDK.
|
|
7
|
+
|
|
8
|
+
It intentionally does **not** include any CLI, HTTP client, or
|
|
9
|
+
audience-specific helpers. Those live in the corresponding packages:
|
|
10
|
+
|
|
11
|
+
- `unitysvc-sellers` — seller SDK + `usvc_seller` CLI, catalog builders
|
|
12
|
+
(`populate_from_iterator`, `render_template_file`,
|
|
13
|
+
`convert_convenience_fields_to_documents`, etc.)
|
|
14
|
+
- `unitysvc` — customer SDK + `usvc` CLI
|
|
15
|
+
- `unitysvc-admin` — admin CLI
|
|
16
|
+
|
|
17
|
+
## What's inside
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
src/unitysvc_core/
|
|
21
|
+
├── models/
|
|
22
|
+
│ ├── base.py # All enums (status, type, currency, rate-limit, etc.)
|
|
23
|
+
│ ├── pricing.py # PriceStr, UsageData, all *PriceData classes,
|
|
24
|
+
│ │ # Pricing union, validate_pricing, cost calculation
|
|
25
|
+
│ ├── documents.py # DocumentData
|
|
26
|
+
│ ├── service.py # RateLimit, ServiceConstraints, AccessInterfaceData,
|
|
27
|
+
│ │ # UpstreamAccessConfigData
|
|
28
|
+
│ ├── validators.py # validate_name, validate_service_options,
|
|
29
|
+
│ │ # suggest_valid_name, SUPPORTED_SERVICE_OPTIONS
|
|
30
|
+
│ ├── provider_v1.py # Provider file schema + ProviderData
|
|
31
|
+
│ ├── offering_v1.py # Offering file schema + ServiceOfferingData
|
|
32
|
+
│ ├── listing_v1.py # Listing file schema + ServiceListingData
|
|
33
|
+
│ ├── promotion_v1.py # Promotion file schema + PromotionData
|
|
34
|
+
│ └── service_group_v1.py
|
|
35
|
+
├── schema/ # Generated JSON schemas (run scripts/generate_schemas.py; CI enforces sync)
|
|
36
|
+
├── validator.py # DataValidator — per-file schema validation,
|
|
37
|
+
│ # union-field reference checks, API-key secret
|
|
38
|
+
│ # scanning, Jinja2 syntax validation, etc.
|
|
39
|
+
└── utils.py # Content hashing, mime/extension helpers,
|
|
40
|
+
# data file loaders (JSON/TOML + overrides),
|
|
41
|
+
# schema-based file discovery
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### What's deliberately NOT here
|
|
45
|
+
|
|
46
|
+
- **CLI code** — `typer`, `rich`, no entry points
|
|
47
|
+
- **HTTP clients** — no `httpx`, no API wrappers
|
|
48
|
+
- **Seller catalog builders** — `populate_from_iterator`,
|
|
49
|
+
`render_template_file`, `convert_convenience_fields_to_documents`,
|
|
50
|
+
`execute_script_content` live in `unitysvc-sellers`
|
|
51
|
+
- **Seller-catalog-layout validation** — `validate_provider_status`,
|
|
52
|
+
`validate_all_service_directories`, `resolve_provider_name`,
|
|
53
|
+
`resolve_service_name_for_listing` live in `unitysvc-sellers`
|
|
54
|
+
|
|
55
|
+
## Installation
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install unitysvc-core
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Dependencies: `pydantic`, `email-validator`, `jsonschema`, `jinja2`,
|
|
62
|
+
`tomli-w`. No `typer`, no `rich`, no `httpx`.
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from pathlib import Path
|
|
68
|
+
|
|
69
|
+
import unitysvc_core
|
|
70
|
+
from unitysvc_core.models import ProviderV1, OfferingV1, ListingV1
|
|
71
|
+
from unitysvc_core.models.pricing import UsageData, validate_pricing
|
|
72
|
+
from unitysvc_core.validator import DataValidator
|
|
73
|
+
|
|
74
|
+
# Validate a single catalog directory
|
|
75
|
+
validator = DataValidator(
|
|
76
|
+
data_dir=Path("./catalog"),
|
|
77
|
+
schema_dir=Path(unitysvc_core.__file__).parent / "schema",
|
|
78
|
+
)
|
|
79
|
+
results = validator.validate_all()
|
|
80
|
+
|
|
81
|
+
# Typed pricing + cost calculation
|
|
82
|
+
pricing = validate_pricing({
|
|
83
|
+
"type": "one_million_tokens",
|
|
84
|
+
"input": "0.50",
|
|
85
|
+
"output": "1.50",
|
|
86
|
+
})
|
|
87
|
+
cost = pricing.calculate_cost(UsageData(input_tokens=1_000, output_tokens=500))
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## History
|
|
91
|
+
|
|
92
|
+
This package was split out of `unitysvc-services` (see
|
|
93
|
+
[unitysvc/unitysvc-services#99](https://github.com/unitysvc/unitysvc-services/issues/99)).
|
|
94
|
+
The seller CLI, catalog builders, and seller-catalog-layout validators
|
|
95
|
+
moved to `unitysvc-sellers`.
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT
|
|
100
|
+
|
|
101
|
+
### Required enrollment parameter usage
|
|
102
|
+
|
|
103
|
+
The shared validator rejects required user parameters that no channel, interface,
|
|
104
|
+
or document consumes. Only those consumer fields count — the offering's
|
|
105
|
+
`upstream_access_config`, the listing's `user_access_interfaces`, and document
|
|
106
|
+
content (a `.j2` document's template source, which the seller validator reads from
|
|
107
|
+
disk as the SDK uploads it); metadata such as `display_name` or `description` is
|
|
108
|
+
never rendered with `params`. File validation checks a listing against its paired offering
|
|
109
|
+
when present; backend ingest calls the same `validate_required_parameter_usage`
|
|
110
|
+
helper with the complete listing and offering. Schema declarations alone are not
|
|
111
|
+
consumers. Jinja attribute access, string-key lookups, and `params.get("name")`
|
|
112
|
+
are inspected; dynamic access and whole-map iteration conservatively consume all
|
|
113
|
+
parameters. Deprecated content can still be retired.
|
|
114
|
+
|
|
115
|
+
This validates usage, not channel attribution: a document reference counts as a
|
|
116
|
+
consumer without proving that the intended paid channel takes enrollment.
|
|
117
|
+
|
|
118
|
+
### Canonical user access interfaces
|
|
119
|
+
|
|
120
|
+
A user access interface is the service's canonical address, the same for every caller, so `validate_listing_interfaces_are_canonical` refuses one that reads a per-enrollment value (`enrollment.*`, `params.*`). An enrollment is reached through the address the platform gives it: `/e/<CODE>` on the API gateway, which a listing never declares. The only per-enrollment form allowed is where the protocol has no such path and the code itself is the address: an SMTP `routing_key.username` or an S3 bucket segment that is exactly `{{ enrollment.code }}`. Values the same for every caller (`service_name`, `routing_vars`) stay allowed. The shared validator runs it on every `listing_v1`; backend ingest calls the same function (unitysvc/unitysvc#2726).
|
|
121
|
+
|
|
122
|
+
### Listing audience
|
|
123
|
+
|
|
124
|
+
A listing may say who the service is for and what a buyer must know before enrolling, as `audience`: a short list of plain-text phrases (unitysvc/unitysvc#2717):
|
|
125
|
+
|
|
126
|
+
```json
|
|
127
|
+
"audience": [
|
|
128
|
+
"Rice University students (enrollment at Rice required)",
|
|
129
|
+
"US customers only / export restrictions may apply"
|
|
130
|
+
]
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
At most 8 phrases, each 1-120 characters after trimming, no URLs or markup (details belong in the description or documents), and no phrase twice. The phrases are shown before enrollment and read by assistants matching services to customers; the platform never enforces them. `capabilities` says what a service does; `audience` says who it is for.
|
|
134
|
+
|
|
135
|
+
### Exports and customer access requests
|
|
136
|
+
|
|
137
|
+
Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it exposes), a seller's **`customer_access_requests`** (what the seller asks the enrolling customer to approve: using other services through that customer's account and authority), and the issued **key's policy** (what the customer actually approved). There is no central list of services. `unitysvc_core.grants` holds the formats and the checks; it does no I/O.
|
|
138
|
+
|
|
139
|
+
**Invariant.** Calls made under an approved request run under the enrolling customer's delegated identity, within the approved permissions, options and limits. Looking up a target's `exports` validates a request's *capabilities*; customer consent and key issuance establish its *authority*. A request grants nothing by itself.
|
|
140
|
+
|
|
141
|
+
**1. The `exports` format (the offering, top level).** How other services may use this one:
|
|
142
|
+
|
|
143
|
+
```json
|
|
144
|
+
"exports": {
|
|
145
|
+
"permissions": {"read": ["GET /records/*"], "write": ["POST /records", "PUT /records/*"]},
|
|
146
|
+
"options": {
|
|
147
|
+
"type": "object",
|
|
148
|
+
"properties": {
|
|
149
|
+
"log_level": {"type": "string", "enum": ["debug", "info", "warn", "error"], "default": "info",
|
|
150
|
+
"title": "Lowest level recorded"},
|
|
151
|
+
"max_size": {"type": "integer", "minimum": 1, "maximum": 10000}
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
"options_ui_schema": {"log_level": {"ui:widget": "radio"}}
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
- `invoke` is always exported and covers every route; it may not be redeclared. A service that declares nothing (most of them) exports `invoke` only, with no options, which `service:<name>` or `service:*` covers.
|
|
159
|
+
- `permissions`: a name → the routes it covers, each `"<METHOD> /<path>"` (`*` for any method, `*` globs in the path). Permissions are never inferred from HTTP methods.
|
|
160
|
+
- `options`: a JSON Schema (draft 7) object of the query arguments the service accepts as customizations, like an enrollment's `user_parameters_schema`, with an optional react-jsonschema-form `options_ui_schema`; the frontend renders it with the same form component. It covers limits only the service can apply (a message size). Restrictions so every value is one query argument: property names are lowercase letters, digits and `_`; each property is a `string`, `integer`, `number`, `boolean` or an `array` of strings, serialized as an OpenAPI form-style query parameter with arrays comma-joined (`style: form, explode: false`); options are optional (no `required`) and only declared properties are accepted; the schema is self-contained (no `$ref`), so validating against it never fetches anything. A property's `default` is what the service does when the option is not sent.
|
|
161
|
+
|
|
162
|
+
**2. A customer access request (`upstream_access_config.<channel>.customer_access_requests`), and the policy entry it becomes.** Any channel may declare its own list; each approved request becomes an entry (a grant) in the issued key's policy, in the same shape:
|
|
163
|
+
|
|
164
|
+
```json
|
|
165
|
+
"customer_access_requests": [{"object": "service:log", "permissions": ["invoke"], "required": true,
|
|
166
|
+
"justification": "Records each check you run",
|
|
167
|
+
"options": "log_level=warn",
|
|
168
|
+
"limits": {"count_per_day": 1000}}]
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
- `object` names one service (`service:<name>`, no wildcards). `enrollment:<CODE>` (one enrollment of an enrollable service) may be allowed later.
|
|
172
|
+
- `options` is a query string (`parse_options`): each name once, each value non-empty; with a catalog, each value is converted to its declared type and validated against the target's `options` schema.
|
|
173
|
+
- **Enrollment is where options and limits are set.** In a request, `options` and `limits` only pre-fill the enrollment form for convenience. When enrolling, the customer confirms and sets the options (a form rendered from the target's `options` schema) and the limits, and adds their own limits; the result is the issued key's policy.
|
|
174
|
+
- `limits` (`validate_limits`): `count_per_hour` / `count_per_day` / `count_per_month` / `count_total` (positive integer) and the same for `amount_` (finite positive decimal, as a number or a string). Limits are universal: the platform meters every call, so any service can be limited without exporting anything. The seller's limits are a pre-fill; the customer sets them at enrollment, and an enrollment's own limits (unitysvc/unitysvc#2687) use the same shape. They are counted per policy entry (the enrollment's calls to that service) from the usage stream (counters keyed by scope, metric and window bucket), and the gateway refuses calls once one is exceeded.
|
|
175
|
+
|
|
176
|
+
**3. How options reach the service.** No new channel: the upstream URL. When the gateway constructs the target's upstream URL for a call made under a policy entry, it appends that entry's `options` as query arguments, `?log_level=warn&max_size=2000`, the way query arguments already reach every upstream, `log://` included. Rules:
|
|
177
|
+
|
|
178
|
+
- The gateway first strips any query argument the client sent under one of the target's exported option names, then appends the policy's values. Today the client's arguments win on a name collision, so without this a caller could override its own policy.
|
|
179
|
+
- An option the policy does not set is not sent; the service applies its own default. A plain `service:*` key sends none.
|
|
180
|
+
- Enrollment parameters are unchanged and keep working alongside: `{{ params.X }}` still renders into the channel config, including a templated `base_url` query.
|
|
181
|
+
- The platform's own services are terminated in gateway stages before the upstream URI is built, so each stage reads its options from the request's arguments (`log_dispatch` drops entries below `log_level`).
|
|
182
|
+
- SMTP has no query string; options for pure-SMTP delivery are out of scope.
|
|
183
|
+
|
|
184
|
+
**4. Where exports are published.** In the public catalog bundle: every service row carries its `exports` exactly as declared (`null` when none). `GET /v1/catalog/bundle` returns the bundle's URL; a `service:<name>` is the row whose `name` is `<name>`.
|
|
185
|
+
|
|
186
|
+
**5. Validating `customer_access_requests`.** `validate_channel_access_requests(upstream_access_config, resolve)` checks every channel's requests. Limits and a default claim (`invoke`, no options) need no lookup. For any other claim, `resolve(name)` returns the target's `exports` from the catalog (`{}` when it declares none) or `None` when no such service is published; core parses it with `parse_exports` and checks the claimed permissions and the options (names, then values against its `options` schema) against it, refusing an unpublished target. Without a resolver only the shape is checked.
|
|
187
|
+
|
|
188
|
+
- `DataValidator(data_dir, schema_dir, exports_resolver=...)` runs both checks on every `offering_v1` (`exports` and each channel's `customer_access_requests`). The seller SDK passes a resolver that downloads the catalog bundle once and reads the target row's `exports`; offline it passes none and gets the shape checks.
|
|
189
|
+
- Backend ingest calls the same function with a resolver over the published services, so an unknown target, permission or option fails at upload.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "unitysvc-core"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.36"
|
|
8
8
|
description = "Shared data models and validation helpers for UnitySVC packages"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [{ name = "Bo Peng", email = "bo.peng@unitysvc.com" }]
|
|
@@ -19,7 +19,7 @@ classifiers = [
|
|
|
19
19
|
dependencies = [
|
|
20
20
|
"pydantic>=2",
|
|
21
21
|
"email-validator",
|
|
22
|
-
"jsonschema",
|
|
22
|
+
"jsonschema>=4.18",
|
|
23
23
|
"jinja2",
|
|
24
24
|
"tomli-w",
|
|
25
25
|
# Presets exposed under $<fn> sentinels that load_data_file expands
|