osp-provider-sdk 0.1.0__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.
- osp_provider_sdk-0.1.0/.gitignore +6 -0
- osp_provider_sdk-0.1.0/CHANGELOG.md +8 -0
- osp_provider_sdk-0.1.0/LICENSE +21 -0
- osp_provider_sdk-0.1.0/PKG-INFO +371 -0
- osp_provider_sdk-0.1.0/README.md +342 -0
- osp_provider_sdk-0.1.0/examples/__init__.py +1 -0
- osp_provider_sdk-0.1.0/examples/backend.py +93 -0
- osp_provider_sdk-0.1.0/examples/buckets.py +158 -0
- osp_provider_sdk-0.1.0/examples/manifest.json +94 -0
- osp_provider_sdk-0.1.0/examples/policy.yaml +20 -0
- osp_provider_sdk-0.1.0/pyproject.toml +119 -0
- osp_provider_sdk-0.1.0/scripts/publish.sh +83 -0
- osp_provider_sdk-0.1.0/src/osp_provider_sdk/__init__.py +61 -0
- osp_provider_sdk-0.1.0/src/osp_provider_sdk/__main__.py +33 -0
- osp_provider_sdk-0.1.0/src/osp_provider_sdk/app.py +591 -0
- osp_provider_sdk-0.1.0/src/osp_provider_sdk/bridge.py +92 -0
- osp_provider_sdk-0.1.0/src/osp_provider_sdk/context.py +201 -0
- osp_provider_sdk-0.1.0/src/osp_provider_sdk/manifest.py +141 -0
- osp_provider_sdk-0.1.0/src/osp_provider_sdk/py.typed +0 -0
- osp_provider_sdk-0.1.0/src/osp_provider_sdk/version.py +3 -0
- osp_provider_sdk-0.1.0/tests/test_conformance.py +63 -0
- osp_provider_sdk-0.1.0/tests/test_sdk.py +389 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 - 2026-10-17
|
|
4
|
+
|
|
5
|
+
- Declare scalar action inputs and resource lists in one file.
|
|
6
|
+
- Share planning across assessment, checks and execution, with frozen approval.
|
|
7
|
+
- Run synchronous and asynchronous handlers with an explicit backend lifetime.
|
|
8
|
+
- Report stages, bounded progress, cooperative cancellation and survivors.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 IT-OPS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: osp-provider-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Optional declaration, planning and progress helpers for OSP providers.
|
|
5
|
+
Author: OSP Team
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.13
|
|
15
|
+
Requires-Dist: loguru<1,>=0.7
|
|
16
|
+
Requires-Dist: osp-provider-contracts<0.10,>=0.9.0
|
|
17
|
+
Requires-Dist: osp-provider-runtime<0.11,>=0.10.0
|
|
18
|
+
Requires-Dist: pydantic<3,>=2.11.7
|
|
19
|
+
Requires-Dist: pyyaml<7,>=6.0.2
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: build<2,>=1.2; extra == 'dev'
|
|
22
|
+
Requires-Dist: hatch<2,>=1.14; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest<10,>=9.0.3; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff>=0.15; extra == 'dev'
|
|
25
|
+
Requires-Dist: twine<7,>=6; extra == 'dev'
|
|
26
|
+
Requires-Dist: ty>=0.0.89; extra == 'dev'
|
|
27
|
+
Requires-Dist: uv<0.13,>=0.12.5; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# osp-provider-sdk
|
|
31
|
+
|
|
32
|
+
A thin, optional authoring layer for OSP providers. Declare actions and lists,
|
|
33
|
+
plan before effects, and report progress through the existing Provider protocol.
|
|
34
|
+
The package layers are **contracts → runtime → SDK → providers**. The
|
|
35
|
+
orchestrator never installs the SDK; existing providers can keep their own
|
|
36
|
+
Provider implementations.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install osp-provider-sdk
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## One-file provider
|
|
43
|
+
|
|
44
|
+
This is [examples/buckets.py](examples/buckets.py). Replace `backend` with your
|
|
45
|
+
team's adapter. The included [backend](examples/backend.py) is in-memory and
|
|
46
|
+
makes no network calls; real backends must persist the task's client token for
|
|
47
|
+
recovery across processes. Do not adopt a bucket merely because its name matches.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
"""S3-style bucket provider on the OSP SDK, with a replaceable backend adapter."""
|
|
51
|
+
|
|
52
|
+
from contextlib import asynccontextmanager
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
from typing import Literal
|
|
55
|
+
|
|
56
|
+
from osp_provider_contracts.canonical_digest import canonical_digest
|
|
57
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
58
|
+
|
|
59
|
+
from osp_provider_sdk import App, Done, Page, Plan, Resource, ResourceOwnership, Violation
|
|
60
|
+
|
|
61
|
+
from . import backend
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@asynccontextmanager
|
|
65
|
+
async def lifespan():
|
|
66
|
+
async with backend.Client.from_env() as client:
|
|
67
|
+
yield client
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
app = App("s3", policy=Path(__file__).with_name("policy.yaml"), lifespan=lifespan)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Create(BaseModel):
|
|
74
|
+
model_config = ConfigDict(frozen=True)
|
|
75
|
+
quota_gib: int = Field(10, ge=1, le=1000, title="Quota (GiB)")
|
|
76
|
+
region: Literal["osl", "bgo"] = "osl"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Resize(BaseModel):
|
|
80
|
+
model_config = ConfigDict(frozen=True)
|
|
81
|
+
quota_gib: int = Field(ge=1, le=1000, title="Quota (GiB)")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Row(BaseModel):
|
|
85
|
+
quota_gib: int = Field(title="Quota (GiB)")
|
|
86
|
+
region: str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _row(b) -> Resource:
|
|
90
|
+
return Resource(
|
|
91
|
+
target_ref=b.id,
|
|
92
|
+
display=b.name,
|
|
93
|
+
ownership=ResourceOwnership(contacts=b.contacts),
|
|
94
|
+
fields=Row(quota_gib=b.quota_gib, region=b.region).model_dump(),
|
|
95
|
+
created_at=b.created,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.resource("bucket", fields=Row, filters=["region"])
|
|
100
|
+
async def buckets(q, access) -> Page:
|
|
101
|
+
page = await app.state.list(region=q.filters.get("region"), cursor=q.cursor, limit=q.limit)
|
|
102
|
+
return Page(
|
|
103
|
+
tuple(_row(b) for b in page.items if access.allows(b.contacts)),
|
|
104
|
+
page.cursor,
|
|
105
|
+
observed_at=page.observed_at,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@buckets.get
|
|
110
|
+
async def bucket(ref, access) -> Resource | None:
|
|
111
|
+
b = await app.state.get(ref)
|
|
112
|
+
return _row(b) if b and access.allows(b.contacts) else None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@app.action("create", inputs=Create, stages=["s3.create", "s3.tag"])
|
|
116
|
+
async def create(req, ctx) -> Done:
|
|
117
|
+
with ctx.step(
|
|
118
|
+
"s3.create",
|
|
119
|
+
f"Creating bucket {req.name}: {req.inputs.quota_gib} GiB in {req.inputs.region}",
|
|
120
|
+
):
|
|
121
|
+
b = await app.state.create(
|
|
122
|
+
req.name, req.inputs.region, req.inputs.quota_gib, client_token=ctx.task_ref
|
|
123
|
+
)
|
|
124
|
+
ctx.created(b.id, req.name)
|
|
125
|
+
with ctx.step("s3.tag", f"Recording owners {', '.join(ctx.contacts)} on {req.name}"):
|
|
126
|
+
await app.state.tag(b.id, contacts=ctx.contacts)
|
|
127
|
+
return Done(f"Created bucket {req.name} ({b.id})")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@create.plan
|
|
131
|
+
async def plan_create(req, ctx) -> Plan:
|
|
132
|
+
if not req.name:
|
|
133
|
+
return Plan(
|
|
134
|
+
"Missing bucket name",
|
|
135
|
+
violations=(Violation.missing_required("name", "A bucket name is required"),),
|
|
136
|
+
)
|
|
137
|
+
taken = await app.state.get_by_name(req.name)
|
|
138
|
+
if taken and taken.client_token != ctx.task_ref:
|
|
139
|
+
return Plan(
|
|
140
|
+
f"bucket {req.name}: name taken",
|
|
141
|
+
violations=(
|
|
142
|
+
Violation.invalid_value("name", f"Bucket {req.name} already exists ({taken.id})."),
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
return Plan(
|
|
146
|
+
f'create bucket "{req.name}" in {req.inputs.region}, quota {req.inputs.quota_gib} GiB, '
|
|
147
|
+
f"owners {', '.join(sorted(ctx.contacts))}"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@app.action("resize", target="resource", inputs=Resize)
|
|
152
|
+
async def resize(req, ctx) -> Done:
|
|
153
|
+
with ctx.step(
|
|
154
|
+
"s3.quota", f"Setting {req.target_ref} quota {ctx.plan.data} → {req.inputs.quota_gib} GiB"
|
|
155
|
+
):
|
|
156
|
+
await app.state.set_quota(req.target_ref, req.inputs.quota_gib)
|
|
157
|
+
return Done(f"Bucket {req.target_ref} quota is {req.inputs.quota_gib} GiB")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@resize.plan
|
|
161
|
+
async def plan_resize(req, ctx) -> Plan:
|
|
162
|
+
b = await app.state.get(req.target_ref)
|
|
163
|
+
if b is None:
|
|
164
|
+
return Plan(
|
|
165
|
+
"Bucket not found",
|
|
166
|
+
violations=(Violation.invalid_value("target_ref", "Bucket does not exist"),),
|
|
167
|
+
)
|
|
168
|
+
if b.quota_gib == req.inputs.quota_gib:
|
|
169
|
+
return Plan.nothing_to_do(f'bucket {b.id} "{b.name}": quota already {b.quota_gib} GiB')
|
|
170
|
+
return Plan(
|
|
171
|
+
f'bucket {b.id} "{b.name}": quota {b.quota_gib} → {req.inputs.quota_gib} GiB',
|
|
172
|
+
data=b.quota_gib,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@app.action(
|
|
177
|
+
"destroy",
|
|
178
|
+
target="resource",
|
|
179
|
+
destructive=True,
|
|
180
|
+
cancellable=True,
|
|
181
|
+
stages=["s3.empty", "s3.delete"],
|
|
182
|
+
)
|
|
183
|
+
async def destroy(req, ctx) -> Done:
|
|
184
|
+
with ctx.step("s3.empty", f"Emptying bucket {req.target_ref}"):
|
|
185
|
+
async for done, total in app.state.empty(req.target_ref, objects=ctx.plan.data):
|
|
186
|
+
ctx.progress(done, total, f"Emptied {done} of {total} prefixes in {req.target_ref}")
|
|
187
|
+
ctx.checkpoint() # The previous prefix delete has settled.
|
|
188
|
+
with ctx.step("s3.delete", f"Deleting bucket {req.target_ref}"):
|
|
189
|
+
await app.state.delete(req.target_ref)
|
|
190
|
+
return Done(f"Deleted bucket {req.target_ref}")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@destroy.plan
|
|
194
|
+
async def plan_destroy(req, ctx) -> Plan:
|
|
195
|
+
b = await app.state.get(req.target_ref)
|
|
196
|
+
if b is None:
|
|
197
|
+
return Plan.nothing_to_do(f"bucket {req.target_ref}: already absent")
|
|
198
|
+
objects = frozenset(b.objects)
|
|
199
|
+
scope = canonical_digest(sorted(objects))
|
|
200
|
+
return Plan(
|
|
201
|
+
f'delete bucket {b.id} "{b.name}" and {len(objects)} object versions (scope {scope})',
|
|
202
|
+
data=objects,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
if __name__ == "__main__":
|
|
207
|
+
app.run()
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
The matching [policy.yaml](examples/policy.yaml) is loaded with `yaml.safe_load`
|
|
211
|
+
and validated against the generated manifest before entering lifespan:
|
|
212
|
+
|
|
213
|
+
```yaml
|
|
214
|
+
request_policy:
|
|
215
|
+
version: 1
|
|
216
|
+
rules:
|
|
217
|
+
- id: team_access
|
|
218
|
+
match: {kind: bucket, actions: [create, resize, destroy], instance: dev}
|
|
219
|
+
check: {netgroup_in: [team-storage]}
|
|
220
|
+
level: gate
|
|
221
|
+
approval: {kind: maintainer, required_approvals: 1}
|
|
222
|
+
message: This team needs a storage operator's approval.
|
|
223
|
+
- id: quota_warning
|
|
224
|
+
match: {kind: bucket, actions: [create, resize]}
|
|
225
|
+
check: {input: quota_gib, max: 80}
|
|
226
|
+
level: warn
|
|
227
|
+
message: This is a large bucket quota.
|
|
228
|
+
- id: quota_approval
|
|
229
|
+
match: {kind: bucket, actions: [create, resize]}
|
|
230
|
+
check: {input: quota_gib, max: 100}
|
|
231
|
+
level: gate
|
|
232
|
+
approval: {kind: maintainer, required_approvals: 1}
|
|
233
|
+
message: Quotas above 100 GiB need storage operator approval.
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
The default destructive-action Gate requires one Maintainer approval. To change
|
|
237
|
+
it provider-wide, add `delete_approval: {kind: admin, required_approvals: 1}`
|
|
238
|
+
under `request_policy`. `peer` is also supported. Rules belong to contracts;
|
|
239
|
+
backend-specific decisions stay in the custom read-only planner.
|
|
240
|
+
|
|
241
|
+
## Invocation and lifetime
|
|
242
|
+
|
|
243
|
+
`app.run()` reads `RABBITMQ_URL`, `PROVIDER_INSTANCE`, `PROVIDER_INSTANCE_ID`
|
|
244
|
+
and `CONCURRENCY` (default 1). Queue names come from `ProviderIdentity`.
|
|
245
|
+
For example, `PROVIDER_INSTANCE=dev python -m examples.buckets` starts the
|
|
246
|
+
example's RabbitMQ runner when `RABBITMQ_URL` is provided.
|
|
247
|
+
|
|
248
|
+
Offline invocation uses `with app.running():`. This validates declarations,
|
|
249
|
+
enters lifespan, and owns one event loop if any callback or lifespan is async.
|
|
250
|
+
All async backend clients are created and closed on that loop. Runtime workers
|
|
251
|
+
block for their handler's result, so runtime concurrency still bounds work.
|
|
252
|
+
Async handlers must move blocking backend calls into `asyncio.to_thread`.
|
|
253
|
+
Sync handlers returning awaitables fail with an instruction to use `async def`.
|
|
254
|
+
|
|
255
|
+
```python
|
|
256
|
+
from osp_provider_contracts import ProviderRequest, RequestContext
|
|
257
|
+
from examples.buckets import app
|
|
258
|
+
|
|
259
|
+
with app.running():
|
|
260
|
+
result = app.execute(
|
|
261
|
+
ProviderRequest("create", name="logs"),
|
|
262
|
+
RequestContext("local-task", netgroup="team-storage", contacts=("storage@example.org",)),
|
|
263
|
+
)
|
|
264
|
+
print(result.produced_resources)
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
## Planning and approval
|
|
268
|
+
|
|
269
|
+
Assessment, CHECK and APPLY use one planner: model validation → shared rules →
|
|
270
|
+
custom plan → combination. Assessment always describes APPLY intent and has no
|
|
271
|
+
cleared approvals. It returns structured rejection, incomplete or approval
|
|
272
|
+
facts without calling apply. CHECK and no-op plans stop before apply.
|
|
273
|
+
|
|
274
|
+
Gated actions must declare `.plan` and author a concrete summary. The Gate key
|
|
275
|
+
names a requirement; its fingerprint covers every frozen Gate field except the
|
|
276
|
+
key, including summary and effective inputs. Use native IDs, before/after values
|
|
277
|
+
and material destructive scope. Bind destructive work to a content revision or
|
|
278
|
+
immutable object-version scope, including its fingerprint in public evidence.
|
|
279
|
+
Apply must enforce that scope with conditional writes and refuse a nonempty
|
|
280
|
+
bucket delete. Leave out secrets, timestamps and unrelated volatile counts.
|
|
281
|
+
If the planned work changes, approval must be obtained again. Private `Plan.data`
|
|
282
|
+
never enters the public assessment; the checked frozen input model and the same
|
|
283
|
+
Plan reach apply.
|
|
284
|
+
|
|
285
|
+
Custom plans must be read-only. Apply cannot request approval: a late
|
|
286
|
+
`ApprovalRequired` becomes `late_gate_contract_violation` with uncertain state.
|
|
287
|
+
Keep live-state-dependent authorization wholly in the custom plan when shared
|
|
288
|
+
rules over model defaults would check different values from those executed.
|
|
289
|
+
|
|
290
|
+
## Progress, cancellation and results
|
|
291
|
+
|
|
292
|
+
Stages use `<system>.<verb>` keys. Declared `stages` determine 1-based position;
|
|
293
|
+
repeat and skip them freely. Progress counts are monotonic within a stage. The
|
|
294
|
+
first and final counts are sent, intermediate updates are limited to one every
|
|
295
|
+
two seconds, and the latest suppressed count is flushed on stage exit. Evidence
|
|
296
|
+
is a JSON-safe mapping under 2 KiB without reserved progress keys. Offline calls
|
|
297
|
+
log progress. Delivery failures do not fail backend work.
|
|
298
|
+
|
|
299
|
+
`cancellable=True` is cooperative. `cancel_task` accepts a request; only
|
|
300
|
+
`ctx.checkpoint()` stops work. Call it only after prior backend operations have
|
|
301
|
+
settled and the current state is safe to leave. Stage entry and progress never
|
|
302
|
+
stop work. A request after the last checkpoint can finish normally.
|
|
303
|
+
|
|
304
|
+
Use `Done` for ordinary success or return a full `ProviderResult` for advanced
|
|
305
|
+
outcomes (`pending_retry`, provenance, mixed resources). Advanced results retain
|
|
306
|
+
all their fields. Record each survivor with `ctx.created`, specifying `kind=` for
|
|
307
|
+
mixed kinds, and call `ctx.removed` only after confirmed cleanup. Survivors are
|
|
308
|
+
merged into success, typed errors, unexpected failures and cancellation results.
|
|
309
|
+
There is no rollback or durable SDK journal. A retried task must rediscover its
|
|
310
|
+
resources through provider-owned backend identity.
|
|
311
|
+
|
|
312
|
+
## Manifest subset
|
|
313
|
+
|
|
314
|
+
v1 supports scalar `str`, `int`, `float`, `bool`, scalar `Literal`, defaults,
|
|
315
|
+
integer `ge`/`le`, string `pattern`, title/description, canonical `Field(alias=...)`,
|
|
316
|
+
`ResourceRef`, and field extras `unit`, `group`, `semantic`. Action models must be
|
|
317
|
+
frozen. Unsupported constraints, models, serializers, validators and metadata
|
|
318
|
+
fail startup. Nested models, unions, lists and secret types are outside v1.
|
|
319
|
+
|
|
320
|
+
Actions expose description, target, destructive, fields and optional stages.
|
|
321
|
+
Resource declarations expose list fields and filters. Exact `get` is optional;
|
|
322
|
+
without one the SDK walks list cursors until the native reference is found.
|
|
323
|
+
Undeclared row fields are dropped while downloads, timestamps and other native
|
|
324
|
+
metadata are retained. `Page` is the contracts `ResourceListResponse`: pass its
|
|
325
|
+
required `observed_at` freshness timestamp. `App.list_resources` returns the
|
|
326
|
+
existing runtime mapping shape; list callbacks return `Page`.
|
|
327
|
+
|
|
328
|
+
There are no capability overlays, native backend cancellation, console/build-log
|
|
329
|
+
callbacks, structured inputs, placement engines or image workflows in v1.
|
|
330
|
+
Providers with those needs can retain their own Provider implementation.
|
|
331
|
+
|
|
332
|
+
## Development and snapshots
|
|
333
|
+
|
|
334
|
+
Install the released contracts/runtime dependencies and SDK development tools:
|
|
335
|
+
|
|
336
|
+
```bash
|
|
337
|
+
uv venv --python 3.13
|
|
338
|
+
uv pip install -e '.[dev]'
|
|
339
|
+
.venv/bin/ruff check .
|
|
340
|
+
.venv/bin/ty check src tests
|
|
341
|
+
.venv/bin/pytest
|
|
342
|
+
.venv/bin/python -m osp_provider_sdk manifest examples.buckets:app --check examples/manifest.json
|
|
343
|
+
.venv/bin/python -m build
|
|
344
|
+
.venv/bin/twine check dist/*
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
Generate snapshots with `python -m osp_provider_sdk manifest module:app`.
|
|
348
|
+
`--check [path]` compares exact canonical JSON, defaulting to `manifest.json`.
|
|
349
|
+
Snapshot generation checks startup but never enters lifespan or connects a backend.
|
|
350
|
+
|
|
351
|
+
CI runs the SDK and shared provider conformance suite with lowest-direct and
|
|
352
|
+
highest dependency resolution, checks the manifest, builds both artifacts,
|
|
353
|
+
and imports the installed wheel outside the source directory. Tag CI attaches
|
|
354
|
+
artifacts to a GitHub release; it does not publish to PyPI.
|
|
355
|
+
|
|
356
|
+
## First release
|
|
357
|
+
|
|
358
|
+
Release the orchestrator fingerprint lifecycle first, then contracts 0.9.x,
|
|
359
|
+
runtime 0.10.x, and SDK 0.1.0. Contracts must contain Plan/Gate, cleared
|
|
360
|
+
fingerprints, resource access and request-policy exports; runtime must expose
|
|
361
|
+
`unexpected_provider_error`. Re-run both dependency-resolution CI lanes with
|
|
362
|
+
those released versions. Finish the plan's field-vocabulary prerequisite,
|
|
363
|
+
NREC/tutorial pilot and Q1 audit before declaring adoption ready. Existing
|
|
364
|
+
providers and cookiecutter tests stay in place.
|
|
365
|
+
|
|
366
|
+
The private repository's Actions policy must allow its configured actions.
|
|
367
|
+
Set up the new distribution name and PyPI credentials or trusted publishing.
|
|
368
|
+
After review, quality checks and signed release/tag verification, a maintainer
|
|
369
|
+
can run `bash scripts/publish.sh` (or `-r testpypi`). `--tag` additionally creates,
|
|
370
|
+
verifies and pushes a signed tag after upload; normal development never runs it.
|
|
371
|
+
No release or live environment is needed for the local tests.
|