Skip to content

Commit 27aa93c

Browse files
committed
Add devdock TUS Assembly example
1 parent 70e8ba9 commit 27aa93c

3 files changed

Lines changed: 215 additions & 0 deletions

File tree

examples/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ Transloadit and may create temporary Assemblies or Templates in your account.
2626
These quickstart examples run in CI against a dedicated Transloadit test account, so they
2727
are kept in sync with the SDK and API.
2828

29+
## API2 Contract QA Examples
30+
31+
`api2-devdock-*` examples are runnable usage examples that API2's contract QA runs
32+
against devdock. They expect API2 to inject a scenario JSON file via
33+
`API2_SDK_EXAMPLE_SCENARIO`, so the API/TUS facts stay in API2 contracts while the
34+
example code remains normal SDK usage.
35+
2936
## Advanced Examples
3037

3138
These examples require pre-created Templates and, depending on your Template, third-party
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
"""Run the API2 contract TUS Assembly scenario against a devdock API2 server.
2+
3+
This example is intentionally checked into the SDK repository: it should read
4+
the API/TUS facts from API2's injected scenario JSON and exercise public SDK
5+
methods as normal user code would.
6+
"""
7+
8+
import json
9+
import os
10+
from io import BytesIO
11+
from pathlib import Path
12+
from urllib.parse import quote
13+
14+
from transloadit.client import Transloadit
15+
from tusclient import client as tus
16+
17+
18+
def required_env(name):
19+
value = os.environ.get(name)
20+
if not value:
21+
raise RuntimeError(f"{name} must be set")
22+
return value
23+
24+
25+
def fail(message):
26+
raise RuntimeError(message)
27+
28+
29+
def load_scenario():
30+
configured_path = os.environ.get("API2_SDK_EXAMPLE_SCENARIO")
31+
scenario_path = (
32+
Path(configured_path) if configured_path else Path(__file__).with_name("api2-scenario.json")
33+
)
34+
with scenario_path.open(encoding="utf-8") as scenario_file:
35+
return json.load(scenario_file)
36+
37+
38+
def read_path(value, path_parts, label):
39+
current = value
40+
for part in path_parts:
41+
if isinstance(current, list) and isinstance(part, int):
42+
if part >= len(current):
43+
fail(f"{label} path {path_parts!r} index {part} is out of range")
44+
current = current[part]
45+
continue
46+
47+
if isinstance(current, dict) and isinstance(part, str):
48+
if part not in current:
49+
fail(f"{label} path {path_parts!r} is missing key {part!r}")
50+
current = current[part]
51+
continue
52+
53+
fail(f"{label} path {path_parts!r} cannot read {part!r} from {current!r}")
54+
55+
return current
56+
57+
58+
def resolve_value(value_spec, context, label):
59+
if "value" in value_spec:
60+
return value_spec["value"]
61+
62+
source = value_spec.get("source")
63+
if not isinstance(source, dict):
64+
fail(f"{label} value spec has no literal value or source")
65+
66+
root = source.get("root")
67+
if root not in context:
68+
fail(f"{label} value source root {root!r} is unavailable")
69+
70+
path_parts = source.get("path") or []
71+
if not isinstance(path_parts, list):
72+
fail(f"{label} value source path must be a list")
73+
74+
return read_path(context[root], path_parts, label)
75+
76+
77+
def response_data(response, operation):
78+
data = response.data
79+
if not isinstance(data, dict):
80+
fail(f"{operation} returned non-JSON data: {data!r}")
81+
if data.get("error"):
82+
fail(f"{operation} returned {data.get('error')}: {data.get('message')}")
83+
return data
84+
85+
86+
def create_assembly(client, scenario):
87+
feature = scenario["createTusAssembly"]
88+
input_values = list(feature["input"].values())
89+
if len(input_values) != 1:
90+
fail(f"{feature['featureId']} expected exactly one input value")
91+
92+
response = client.create_tus_assembly(input_values[0])
93+
data = response_data(response, feature["featureId"])
94+
for required_path in feature["requiredResponsePaths"]:
95+
value = read_path(data, required_path, feature["featureId"])
96+
if value is None or value == "":
97+
fail(f"{feature['featureId']} returned empty value at {required_path!r}")
98+
return data
99+
100+
101+
def scenario_bytes(scenario):
102+
source = scenario["upload"]["source"]
103+
if source["kind"] != "bytes":
104+
fail(f"unsupported scenario source kind {source['kind']!r}")
105+
if source["encoding"] != "utf8":
106+
fail(f"unsupported scenario source encoding {source['encoding']!r}")
107+
return source["value"].encode("utf-8")
108+
109+
110+
def upload_metadata(scenario, create_response):
111+
context = {"createResponse": create_response, "scenario": scenario}
112+
metadata = {}
113+
for field in scenario["upload"]["metadata"]:
114+
metadata[field["name"]] = str(resolve_value(field["value"], context, field["name"]))
115+
return metadata
116+
117+
118+
def upload_with_tus(scenario, create_response):
119+
context = {"createResponse": create_response, "scenario": scenario}
120+
endpoint_url = str(resolve_value(scenario["upload"]["tusUrl"], context, "tusUrl"))
121+
content = scenario_bytes(scenario)
122+
chunk_size = len(content) if scenario["upload"]["chunkSize"] == "full-file" else None
123+
if chunk_size is None:
124+
fail(f"unsupported chunk size policy {scenario['upload']['chunkSize']!r}")
125+
126+
uploader = tus.TusClient(endpoint_url).uploader(
127+
file_stream=BytesIO(content),
128+
chunk_size=chunk_size,
129+
metadata=upload_metadata(scenario, create_response),
130+
retries=scenario["upload"]["retries"],
131+
)
132+
uploader.upload()
133+
if not uploader.url:
134+
fail("TUS upload did not expose an upload URL")
135+
if uploader.offset != len(content):
136+
fail(f"TUS upload offset {uploader.offset}, expected {len(content)}")
137+
return uploader.url
138+
139+
140+
def render_path_template(template_config, context, label):
141+
rendered = template_config["template"]
142+
for name, value_spec in template_config["replacements"].items():
143+
value = resolve_value(value_spec, context, f"{label}.{name}")
144+
rendered = rendered.replace("{" + name + "}", quote(str(value), safe=""))
145+
146+
if "{" in rendered or "}" in rendered:
147+
fail(f"{label} still has unresolved placeholders: {rendered}")
148+
149+
return rendered
150+
151+
152+
def wait_for_assembly(client, scenario, create_response):
153+
feature = scenario["waitForAssembly"]
154+
context = {"createResponse": create_response, "scenario": scenario}
155+
wait_input = render_path_template(feature["input"], context, feature["featureId"])
156+
return response_data(client.wait_for_assembly(wait_input), feature["featureId"])
157+
158+
159+
def run_assertions(scenario, create_response, status, upload_url):
160+
context = {
161+
"captured": {"uploadUrl": upload_url},
162+
"createResponse": create_response,
163+
"scenario": scenario,
164+
"status": status,
165+
}
166+
167+
for index, assertion in enumerate(scenario["assertions"]):
168+
label = f"assertions[{index}]"
169+
actual = resolve_value(assertion["actual"], context, f"{label}.actual")
170+
expected = resolve_value(assertion["expected"], context, f"{label}.expected")
171+
172+
if assertion["kind"] == "equals":
173+
if actual != expected:
174+
fail(f"{label} expected {expected!r}, got {actual!r}")
175+
continue
176+
177+
if assertion["kind"] == "length":
178+
if len(actual) != expected:
179+
fail(f"{label} expected length {expected!r}, got {len(actual)!r}")
180+
continue
181+
182+
fail(f"{label} has unsupported assertion kind {assertion['kind']!r}")
183+
184+
185+
def main():
186+
scenario = load_scenario()
187+
endpoint = required_env("TRANSLOADIT_ENDPOINT")
188+
client = Transloadit(
189+
auth_key=required_env("TRANSLOADIT_KEY"),
190+
auth_secret=required_env("TRANSLOADIT_SECRET"),
191+
service=endpoint,
192+
)
193+
194+
create_response = create_assembly(client, scenario)
195+
upload_url = upload_with_tus(scenario, create_response)
196+
status = wait_for_assembly(client, scenario, create_response)
197+
run_assertions(scenario, create_response, status, upload_url)
198+
199+
print(f"Python Transloadit SDK devdock scenario {scenario['scenarioId']} passed for {endpoint}")
200+
201+
202+
if __name__ == "__main__":
203+
main()

tests/test_examples.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ def test_examples_import_without_side_effects():
3636
runpy.run_path(str(example_path), run_name="__example_import__")
3737

3838

39+
def test_devdock_examples_import_without_side_effects():
40+
for example_path in sorted(EXAMPLES_ROOT.glob("api2-devdock-*/main.py")):
41+
runpy.run_path(str(example_path), run_name="__example_import__")
42+
43+
3944
def test_smart_cdn_example_runs_without_network():
4045
env = {
4146
**os.environ,

0 commit comments

Comments
 (0)